@memberjunction/codegen-lib 5.44.0 → 5.45.1

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.
@@ -5,12 +5,13 @@ import { CodeGenDatabaseProvider } from './codeGenDatabaseProvider.js';
5
5
  // and the SS code path silently fails.
6
6
  import './providers/sqlserver/SQLServerCodeGenProvider.js';
7
7
  import { configInfo, currentWorkingDirectory, dbPlatform, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
8
- import { EntityInfo, ExtractActualDefaultValue, LogError, LogStatus, Metadata, SeverityType } from "@memberjunction/core";
8
+ import { EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, SeverityType } from "@memberjunction/core";
9
9
  import { MJEntityFieldSchema } from "@memberjunction/core-entities";
10
10
  import { logError, logMessage, logStatus, startSpinner, updateSpinner, succeedSpinner } from "../Misc/status_logging.js";
11
11
  import { SQLUtilityBase } from "./sql.js";
12
12
  import { AdvancedGeneration } from "../Misc/advanced_generation.js";
13
13
  import { CodeGenReporter } from "../Misc/codegen-reporter.js";
14
+ import { mapExternalNativeTypeToMJ } from "../Misc/externalTypeMapping.js";
14
15
  import { SQLParser } from "@memberjunction/sql-parser";
15
16
  import { createDisplayName, generatePluralName, MJGlobal, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
16
17
  import { v4 as uuidv4 } from 'uuid';
@@ -44,6 +45,23 @@ export class ManageMetadataBase {
44
45
  this._dbProvider = null;
45
46
  // ─── End Dialect Infrastructure ───────────────────────────────────
46
47
  this._sqlUtilityObject = MJGlobal.Instance.ClassFactory.CreateInstance(SQLUtilityBase);
48
+ /**
49
+ * External-data-source analogue of {@link manageVirtualEntities}. For each entity backed by an
50
+ * external data source, introspect the REMOTE schema (via the EDS router resolved through the
51
+ * ClassFactory) and sync its `EntityField` rows to match — the remote equivalent of reading the
52
+ * local INFORMATION_SCHEMA for a virtual/view entity. No-op when there are no external entities.
53
+ */
54
+ /**
55
+ * Whether the current database's `Entity` table has the `ExternalDataSourceID` column. External
56
+ * Data Sources ship as a SQL Server migration only, so on any database/schema that predates it
57
+ * (PostgreSQL today) the column is absent — and ANY raw SQL referencing it throws
58
+ * "column does not exist" and aborts the entire CodeGen run, even for a pure MJ-DB schema with no
59
+ * external entities. Callers gate EDS-specific queries on this so CodeGen stays green everywhere
60
+ * and auto-activates once the column lands on other platforms (mirrors the PG stored-proc guard in
61
+ * metadataSupportObjects.ts). Cross-platform via INFORMATION_SCHEMA; cached for the run (the schema
62
+ * cannot change mid-run).
63
+ */
64
+ this._entityHasExternalDataSourceColumn = null;
47
65
  }
48
66
  /**
49
67
  * Returns the CodeGenDatabaseProvider for the current database platform.
@@ -962,6 +980,23 @@ export class ManageMetadataBase {
962
980
  logError(' Error managing virtual entities');
963
981
  bSuccess = false;
964
982
  }
983
+ // External-data-source entities: introspect the REMOTE schema and sync their EntityField rows
984
+ // (the remote analogue of reading the local view's columns for a virtual entity).
985
+ const eeResult = await this.manageExternalEntities(pool, currentUser);
986
+ if (!eeResult.success) {
987
+ logError(' Error managing external data source entities');
988
+ bSuccess = false;
989
+ }
990
+ // External entities are processed AFTER the main manageEntityRelationships pass above, so any
991
+ // foreign keys they introspected (now set as soft FKs) need a second relationship pass to be
992
+ // materialized into EntityRelationship records in this same run.
993
+ if (eeResult.relationshipsUpdated) {
994
+ logStatus(' Managing external-entity relationships...');
995
+ if (!await this.manageEntityRelationships(pool, excludeSchemas, md)) {
996
+ logError(' Error managing external entity relationships');
997
+ bSuccess = false;
998
+ }
999
+ }
965
1000
  // LLM-assisted virtual entity field decoration — identify PKs, FKs, and descriptions
966
1001
  await this.decorateVirtualEntitiesWithLLM(pool, currentUser);
967
1002
  // Config-driven IS-A relationship setup — set ParentID on child entities
@@ -1079,6 +1114,257 @@ export class ManageMetadataBase {
1079
1114
  }
1080
1115
  return { success: bSuccess, anyUpdates: anyUpdates };
1081
1116
  }
1117
+ async entityHasExternalDataSourceColumn(pool) {
1118
+ if (this._entityHasExternalDataSourceColumn === null) {
1119
+ // Check the VIEW the gated queries actually read (vwEntities), not the base Entity table — a
1120
+ // schema where the table has the column but the view wasn't refreshed to expose it would still
1121
+ // throw. (Matches the PG guard in metadataSupportObjects.ts, which also checks the view.)
1122
+ const sql = `SELECT COUNT(*) AS ColExists FROM INFORMATION_SCHEMA.COLUMNS ` +
1123
+ `WHERE TABLE_SCHEMA = '${mj_core_schema()}' AND TABLE_NAME = 'vwEntities' AND COLUMN_NAME = 'ExternalDataSourceID'`;
1124
+ const result = await this.runQuery(pool, sql);
1125
+ const row = (result.recordset?.[0] ?? {});
1126
+ const cnt = row.ColExists ?? row.colexists ?? 0;
1127
+ this._entityHasExternalDataSourceColumn = Number(cnt) > 0;
1128
+ }
1129
+ return this._entityHasExternalDataSourceColumn;
1130
+ }
1131
+ async manageExternalEntities(pool, currentUser) {
1132
+ let bSuccess = true;
1133
+ let anyUpdates = false;
1134
+ let relationshipsUpdated = false;
1135
+ // EDS is provisioned only where its (SQL Server) migration ran. On a database whose Entity table
1136
+ // lacks ExternalDataSourceID (e.g. PostgreSQL), there can be no external entities — and the query
1137
+ // below would throw and abort the whole CodeGen run — so skip cleanly.
1138
+ if (!(await this.entityHasExternalDataSourceColumn(pool))) {
1139
+ return { success: true, anyUpdates: false, relationshipsUpdated: false };
1140
+ }
1141
+ const sql = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntities')} WHERE ExternalDataSourceID IS NOT NULL`;
1142
+ const result = await this.runQuery(pool, sql);
1143
+ const externalEntities = result.recordset;
1144
+ if (!externalEntities || externalEntities.length === 0) {
1145
+ return { success: true, anyUpdates: false, relationshipsUpdated: false };
1146
+ }
1147
+ // Lazily load the External Data Sources engine + drivers — ONLY now that we know external
1148
+ // entities exist. The dynamic import is deliberate (CLAUDE.md rule #8, category 3: build-tool
1149
+ // startup deferral): the common codegen run has no external entities and must not pay the cost
1150
+ // of loading pg/mongodb/the engine. The packages are declared deps; importing each triggers its
1151
+ // @RegisterClass side effects so the ClassFactory can resolve the router + drivers below.
1152
+ // snowflake-sdk is an optional peer the Snowflake driver loads only on connect.
1153
+ await import('@memberjunction/external-data-sources');
1154
+ await import('@memberjunction/external-data-source-postgres');
1155
+ await import('@memberjunction/external-data-source-snowflake');
1156
+ await import('@memberjunction/external-data-source-mongodb');
1157
+ await import('@memberjunction/external-data-source-sqlserver');
1158
+ await import('@memberjunction/external-data-source-mysql');
1159
+ await import('@memberjunction/external-data-source-oracle');
1160
+ const router = MJGlobal.Instance.ClassFactory.CreateInstance(ExternalDataSourceReadRouter);
1161
+ if (!router) {
1162
+ logError(' Cannot sync external entity fields: no ExternalDataSourceReadRouter is registered. Ensure @memberjunction/external-data-sources (and the relevant driver) are loaded in the CodeGen process.');
1163
+ return { success: false, anyUpdates: false, relationshipsUpdated: false };
1164
+ }
1165
+ // Refresh the in-memory metadata cache so the external entities are present for EntityByName()
1166
+ // inside manageSingleExternalEntity / manageSingleVirtualEntityField. External entities are
1167
+ // often created/seeded after the CodeGen process loaded metadata at startup; without this
1168
+ // refresh, EntityByName() returns null and field sync is SILENTLY skipped — the same gotcha
1169
+ // already handled for config-created virtual entities above.
1170
+ const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
1171
+ await md.Refresh();
1172
+ for (const ee of externalEntities) {
1173
+ const { success, updatedEntity, relationshipsUpdated: relUpdated } = await this.manageSingleExternalEntity(pool, ee, router, currentUser);
1174
+ anyUpdates = anyUpdates || updatedEntity;
1175
+ relationshipsUpdated = relationshipsUpdated || relUpdated;
1176
+ if (!success) {
1177
+ logError(` Error managing external entity ${ee.Name}`);
1178
+ bSuccess = false;
1179
+ }
1180
+ }
1181
+ return { success: bSuccess, anyUpdates, relationshipsUpdated };
1182
+ }
1183
+ /**
1184
+ * Whether an introspected remote object has columns we can safely sync. When false (object
1185
+ * missing or zero columns), {@link manageSingleExternalEntity} skips the field sync rather than
1186
+ * treating it as "the entity now has no fields" (which would DELETE every EntityField). Pure/testable.
1187
+ */
1188
+ externalObjectIsSyncable(obj) {
1189
+ return !!(obj && Array.isArray(obj.Columns) && obj.Columns.length > 0);
1190
+ }
1191
+ /**
1192
+ * Builds the DELETE for EntityFields no longer present in the freshly-introspected remote object,
1193
+ * or '' when nothing should be removed. Field-name matching is case-insensitive. Pure/testable
1194
+ * (no DB access) — the data-loss-sensitive computation guarded by {@link externalObjectIsSyncable}.
1195
+ */
1196
+ buildExternalFieldRemoveSQL(schema, existingFields, introspectedFieldNames) {
1197
+ const present = new Set(introspectedFieldNames.map(n => n.trim().toLowerCase()));
1198
+ const removeIds = existingFields.filter(f => !present.has(f.Name.trim().toLowerCase())).map(f => f.ID);
1199
+ if (removeIds.length === 0) {
1200
+ return '';
1201
+ }
1202
+ const idList = removeIds.map(id => `'${id}'`).join(',');
1203
+ // Clear FK dependents (value-list entries) before the EntityField delete to avoid an FK violation,
1204
+ // mirroring spDeleteUnneededEntityFields. Emitted as two statements separated by the platform terminator.
1205
+ return `DELETE FROM ${this.qs(schema, 'EntityFieldValue')} WHERE EntityFieldID IN (${idList});\n` +
1206
+ `DELETE FROM ${this.qs(schema, 'EntityField')} WHERE ID IN (${idList})`;
1207
+ }
1208
+ /**
1209
+ * Introspects the remote schema for a single external entity and syncs its `EntityField` rows.
1210
+ * Mirrors {@link manageSingleVirtualEntity} but sources the field list from the driver's
1211
+ * `IntrospectSchema` (mapped to MJ types via {@link mapExternalNativeTypeToMJ}) instead of the
1212
+ * local view's columns, and reuses {@link manageSingleVirtualEntityField} for create/update.
1213
+ */
1214
+ async manageSingleExternalEntity(pool, externalEntity, router, currentUser) {
1215
+ let bSuccess = true;
1216
+ let bUpdated = false;
1217
+ let bRelationshipsUpdated = false;
1218
+ // Introspection reaches a live remote system. A connection/auth/network failure there must NOT
1219
+ // fail the whole CodeGen run (which also regenerates every MJ-DB entity) — treat it as a
1220
+ // recoverable warning-skip, leaving this entity's existing fields untouched. Genuine errors in
1221
+ // the field-sync logic below still fail (caught by the outer try/catch as success:false).
1222
+ let descriptor;
1223
+ try {
1224
+ descriptor = await router.IntrospectExternalSchema(externalEntity.ExternalDataSourceID, externalEntity.SchemaName || undefined, currentUser);
1225
+ }
1226
+ catch (e) {
1227
+ // Surface via logError (not logStatus) so a genuine driver bug isn't invisible in a green run —
1228
+ // but still recover (success:true) so one unreachable remote source can't fail the whole run.
1229
+ logError(` ⚠️ External entity ${externalEntity.Name}: remote schema introspection failed (${e instanceof Error ? e.message : String(e)}) — skipping this entity (left untouched); CodeGen run continues.`);
1230
+ return { success: true, updatedEntity: false, relationshipsUpdated: false };
1231
+ }
1232
+ try {
1233
+ // Find the remote object backing this entity (prefer ExternalObjectName; fall back to
1234
+ // BaseTable/Name). Match on bare or schema-qualified name, case-insensitively.
1235
+ const target = (externalEntity.ExternalObjectName || externalEntity.BaseTable || externalEntity.Name || '').trim().toLowerCase();
1236
+ const obj = descriptor.Objects.find(o => o.Name.trim().toLowerCase() === target ||
1237
+ `${o.Schema ?? ''}.${o.Name}`.trim().toLowerCase() === target);
1238
+ if (!obj) {
1239
+ logStatus(` ⚠️ External entity ${externalEntity.Name}: remote object '${externalEntity.ExternalObjectName ?? externalEntity.Name}' not found in introspected schema — skipping this entity (left untouched); CodeGen run continues.`);
1240
+ // Recoverable skip (misconfigured/unreachable object): leave the entity untouched and
1241
+ // continue the run rather than failing the whole metadata pass for one external entity.
1242
+ return { success: true, updatedEntity: false, relationshipsUpdated: false };
1243
+ }
1244
+ // Guard the degenerate "found but zero columns" case the same way as "not found": a
1245
+ // permission-limited / transient / partial introspection that returns an object shell with
1246
+ // no columns must NOT be treated as "the entity now has no fields" — that would make the
1247
+ // remove-list below every existing EntityField and DELETE them all (silent metadata loss).
1248
+ // Bail with a warning instead and leave the existing fields untouched.
1249
+ if (!this.externalObjectIsSyncable(obj)) {
1250
+ logStatus(` ⚠️ External entity ${externalEntity.Name}: remote object '${obj.Name}' introspected with zero columns — skipping field sync to avoid destroying existing field metadata; CodeGen run continues.`);
1251
+ // Recoverable skip (permission-limited / transient / partial introspection): leave the
1252
+ // existing fields untouched and continue, rather than failing the whole metadata pass.
1253
+ return { success: true, updatedEntity: false, relationshipsUpdated: false };
1254
+ }
1255
+ // Map each introspected column into the veField shape that manageSingleVirtualEntityField consumes.
1256
+ const eeFields = obj.Columns.map(c => {
1257
+ const t = mapExternalNativeTypeToMJ(c.NativeType);
1258
+ return { FieldName: c.Name, Type: t.Type, Length: t.Length, Precision: t.Precision, Scale: t.Scale, AllowsNull: c.Nullable, IsPrimaryKey: c.IsPrimaryKey };
1259
+ });
1260
+ const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
1261
+ const entity = md.EntityByName(externalEntity.Name);
1262
+ if (entity) {
1263
+ // remove EntityFields no longer present in the remote object
1264
+ const sqlRemove = this.buildExternalFieldRemoveSQL(mj_core_schema(), entity.Fields, eeFields.map(ef => ef.FieldName));
1265
+ if (sqlRemove) {
1266
+ await this.LogSQLAndExecute(pool, sqlRemove, `SQL text to remove fields from external entity ${externalEntity.Name}`);
1267
+ bUpdated = true;
1268
+ }
1269
+ // create/update each remote column. Use the real PK info from introspection; fall back
1270
+ // to "first column is the PK" when the remote reported none (e.g. a view).
1271
+ const anyPk = eeFields.some(f => f.IsPrimaryKey);
1272
+ // How many columns make up the PK? Introspected PK count when present, else 1 (first-column
1273
+ // fallback). A composite PK (>1) means no single column is unique on its own (M4).
1274
+ const primaryKeyColumnCount = anyPk ? eeFields.filter(f => f.IsPrimaryKey).length : 1;
1275
+ const singleColumnPrimaryKey = primaryKeyColumnCount === 1;
1276
+ for (let i = 0; i < eeFields.length; i++) {
1277
+ const ef = eeFields[i];
1278
+ const makePrimaryKey = ef.IsPrimaryKey || (!anyPk && i === 0);
1279
+ // reconcilePrimaryKey=true: introspection is authoritative, so sync PK/Unique flags in BOTH
1280
+ // directions (set AND clear) — this clears a stale PK column when the remote PK moves (H5).
1281
+ const { success, updatedField } = await this.manageSingleVirtualEntityField(pool, externalEntity, ef, i + 1, makePrimaryKey, singleColumnPrimaryKey, true);
1282
+ bUpdated = bUpdated || updatedField;
1283
+ if (!success) {
1284
+ logError(`Error managing external entity field ${ef.FieldName} for external entity ${externalEntity.Name}`);
1285
+ bSuccess = false;
1286
+ }
1287
+ }
1288
+ // Baseline FK consumption: turn introspected single-column foreign keys into soft FKs
1289
+ // (RelatedEntityID + IsSoftForeignKey) so the standard manageEntityRelationships pass
1290
+ // materializes them into EntityRelationship records.
1291
+ if (await this.manageExternalEntityRelationships(pool, externalEntity, obj, md)) {
1292
+ bUpdated = true;
1293
+ bRelationshipsUpdated = true;
1294
+ }
1295
+ }
1296
+ if (bUpdated) {
1297
+ const sqlUpdate = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()} WHERE ID='${externalEntity.ID}'`;
1298
+ await this.LogSQLAndExecute(pool, sqlUpdate, `SQL text to update external entity updated date for ${externalEntity.Name}`);
1299
+ }
1300
+ return { success: bSuccess, updatedEntity: bUpdated, relationshipsUpdated: bRelationshipsUpdated };
1301
+ }
1302
+ catch (e) {
1303
+ logError(e instanceof Error ? e.message : String(e));
1304
+ return { success: false, updatedEntity: bUpdated, relationshipsUpdated: bRelationshipsUpdated };
1305
+ }
1306
+ }
1307
+ /**
1308
+ * Baseline foreign-key consumption for an external entity: for each introspected single-column
1309
+ * relationship whose referenced remote object is ALSO an imported external entity in the same
1310
+ * data source, set the FK field's RelatedEntityID + RelatedEntityFieldName + IsSoftForeignKey=1.
1311
+ * The standard `manageEntityRelationships` pass then materializes these into EntityRelationship
1312
+ * records. Composite FKs and references to objects that aren't imported are skipped (logged) —
1313
+ * those are the follow-up hardening. Returns true if any FK field was updated.
1314
+ */
1315
+ async manageExternalEntityRelationships(pool, externalEntity, obj, md) {
1316
+ const relationships = obj.Relationships ?? [];
1317
+ // No introspected relationships → nothing to set. We deliberately do NOT try to "clear dropped FKs"
1318
+ // here: introspection returning zero relationships is AMBIGUOUS — it happens for drivers that don't
1319
+ // report FK metadata at all (MongoDB), and for low-privilege logins where the catalog FK views are
1320
+ // permission-filtered to zero rows (SQL Server sys.foreign_keys). Clearing on that signal would wipe
1321
+ // user-authored soft-FKs (which are indistinguishable from CodeGen-set ones — there is no marker),
1322
+ // so we leave existing FK metadata untouched. (See the change-detection below for the no-churn set path.)
1323
+ if (relationships.length === 0) {
1324
+ return false;
1325
+ }
1326
+ const dsID = externalEntity.ExternalDataSourceID;
1327
+ const entity = md.EntityByName(externalEntity.Name); // read current field FK metadata (prior-run state)
1328
+ const sqlStatements = [];
1329
+ for (const rel of relationships) {
1330
+ if (rel.Columns.length !== 1) {
1331
+ logStatus(` ⚠ external FK ${externalEntity.Name}.${rel.Name ?? '(unnamed)'}: composite key (${rel.Columns.length} columns) — skipped (baseline supports single-column FKs)`);
1332
+ continue;
1333
+ }
1334
+ const { Column: fkColumn, ReferencedColumn: refColumn } = rel.Columns[0];
1335
+ // Resolve the referenced object to an imported external entity in the SAME data source
1336
+ // (match ExternalObjectName, falling back to BaseTable/Name — mirrors how this entity's
1337
+ // own object is resolved above).
1338
+ const target = rel.ReferencedObject.trim().toLowerCase();
1339
+ const relatedEntity = md.Entities.find(e => e.ExternalDataSourceID && dsID && UUIDsEqual(e.ExternalDataSourceID, dsID) &&
1340
+ ((e.ExternalObjectName || e.BaseTable || e.Name) || '').trim().toLowerCase() === target);
1341
+ if (!relatedEntity) {
1342
+ logStatus(` ⚠ external FK ${externalEntity.Name}.${fkColumn} → '${rel.ReferencedObject}': referenced entity not imported — skipped`);
1343
+ continue;
1344
+ }
1345
+ // Change detection (M5): skip the UPDATE when the field already carries the exact desired FK
1346
+ // metadata — otherwise every CodeGen run re-UPDATEs the row, bumping __mj_UpdatedAt and logging
1347
+ // a spurious change even though nothing changed.
1348
+ const field = entity?.Fields.find(f => f.Name.trim().toLowerCase() === fkColumn.trim().toLowerCase());
1349
+ const alreadySet = !!field && field.IsSoftForeignKey === true &&
1350
+ !!field.RelatedEntityID && UUIDsEqual(field.RelatedEntityID, relatedEntity.ID) &&
1351
+ (field.RelatedEntityFieldName ?? '').trim() === refColumn.trim();
1352
+ if (alreadySet) {
1353
+ continue;
1354
+ }
1355
+ sqlStatements.push(`UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
1356
+ SET RelatedEntityID='${relatedEntity.ID}',
1357
+ RelatedEntityFieldName='${refColumn.replace(/'/g, "''")}',
1358
+ IsSoftForeignKey=1
1359
+ WHERE EntityID='${externalEntity.ID}' AND Name='${fkColumn.replace(/'/g, "''")}'`);
1360
+ logStatus(` ✓ external FK ${externalEntity.Name}.${fkColumn} → ${relatedEntity.Name}.${refColumn}`);
1361
+ }
1362
+ if (sqlStatements.length === 0) {
1363
+ return false;
1364
+ }
1365
+ await this.LogSQLBatchAndExecute(pool, sqlStatements, `Set external foreign keys for ${externalEntity.Name}`);
1366
+ return true;
1367
+ }
1082
1368
  async manageSingleVirtualEntity(pool, virtualEntity) {
1083
1369
  let bSuccess = true;
1084
1370
  let bUpdated = false;
@@ -1132,9 +1418,40 @@ export class ManageMetadataBase {
1132
1418
  return { success: false, updatedEntity: bUpdated };
1133
1419
  }
1134
1420
  }
1135
- async manageSingleVirtualEntityField(pool, virtualEntity, veField, fieldSequence, makePrimaryKey) {
1421
+ /**
1422
+ * Desired PK/Unique flags for a virtual/external entity field. IsPrimaryKey mirrors makePrimaryKey;
1423
+ * IsUnique is true ONLY for a SINGLE-column PK — a column of a COMPOSITE key is not unique on its own,
1424
+ * so composite-key columns get IsUnique=false (M4). Pure function so the rule is unit-tested.
1425
+ */
1426
+ resolvePrimaryKeyFlags(makePrimaryKey, singleColumnPrimaryKey) {
1427
+ return { wantPrimaryKey: makePrimaryKey, wantUnique: makePrimaryKey && singleColumnPrimaryKey };
1428
+ }
1429
+ /**
1430
+ * Whether a field's PK/Unique flags need to change. In RECONCILE mode (external entities, whose
1431
+ * introspected PK set is authoritative across ALL fields) we sync in BOTH directions — setting the
1432
+ * flags when a column becomes a PK AND clearing them when it stops being one (H5: otherwise a stale PK
1433
+ * column keeps IsPrimaryKey=1 when the remote PK moves, yielding duplicate PKs). In NON-reconcile mode
1434
+ * (virtual entities, where makePrimaryKey is a one-time first-column bootstrap, not authoritative) we
1435
+ * only SET on acquisition and never clear — avoiding wiping a legitimately-configured PK, and avoiding
1436
+ * a spurious UPDATE (+ __mj_UpdatedAt bump) every run when nothing changed. Pure function; unit-tested.
1437
+ */
1438
+ primaryKeyFlagsChanged(current, want, makePrimaryKey, reconcile) {
1439
+ if (!reconcile) {
1440
+ return makePrimaryKey && !current.isPrimaryKey;
1441
+ }
1442
+ // Reconcile mode: sync PK status in both directions. IsUnique is managed ONLY in tandem with the
1443
+ // primary key (when the field is, or is becoming, a PK) — we must NOT force IsUnique=0 on an
1444
+ // ordinary non-PK column, which would silently wipe a legitimately-unique field (e.g. a user- or
1445
+ // introspection-marked unique `email`) that has nothing to do with the primary key.
1446
+ const pkStatusChanged = current.isPrimaryKey !== want.wantPrimaryKey;
1447
+ const uniqueIsPkRelated = want.wantPrimaryKey || current.isPrimaryKey;
1448
+ const uniqueChanged = uniqueIsPkRelated && current.isUnique !== want.wantUnique;
1449
+ return pkStatusChanged || uniqueChanged;
1450
+ }
1451
+ async manageSingleVirtualEntityField(pool, virtualEntity, veField, fieldSequence, makePrimaryKey, singleColumnPrimaryKey = true, reconcilePrimaryKey = false) {
1136
1452
  // this protected checks to see if the field exists in the entity definition, and if not, adds it
1137
1453
  // if it exist it updates the entity field to match the view's data type and nullability attributes
1454
+ const { wantPrimaryKey, wantUnique } = this.resolvePrimaryKeyFlags(makePrimaryKey, singleColumnPrimaryKey);
1138
1455
  // first, get the entity definition
1139
1456
  const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
1140
1457
  const entity = md.EntityByName(virtualEntity.Name);
@@ -1143,8 +1460,15 @@ export class ManageMetadataBase {
1143
1460
  if (entity) {
1144
1461
  const field = entity.Fields.find(f => f.Name.trim().toLowerCase() === veField.FieldName.trim().toLowerCase());
1145
1462
  if (field) {
1146
- // have a match, so the field exists in the entity definition, now check to see if it needs to be updated
1147
- if (makePrimaryKey ||
1463
+ // Decide whether the PK/Unique flags need to change. In RECONCILE mode (external entities,
1464
+ // whose introspected PK set is authoritative across ALL fields) we sync in BOTH directions —
1465
+ // setting the flags when a column becomes a PK AND clearing them when it stops being one (H5:
1466
+ // otherwise a stale PK column keeps IsPrimaryKey=1 when the remote PK moves, yielding duplicate
1467
+ // PKs). In NON-reconcile mode (virtual entities, where makePrimaryKey is a one-time first-column
1468
+ // bootstrap, not authoritative) we only SET on acquisition and never clear, to avoid wiping a
1469
+ // legitimately-configured PK — and we avoid re-UPDATING an unchanged row every run.
1470
+ const pkFlagsChanged = this.primaryKeyFlagsChanged({ isPrimaryKey: field.IsPrimaryKey, isUnique: field.IsUnique }, { wantPrimaryKey, wantUnique }, makePrimaryKey, reconcilePrimaryKey);
1471
+ if (pkFlagsChanged ||
1148
1472
  field.Type.trim().toLowerCase() !== veField.Type.trim().toLowerCase() ||
1149
1473
  field.Length !== veField.Length ||
1150
1474
  field.AllowsNull !== veField.AllowsNull ||
@@ -1158,7 +1482,7 @@ export class ManageMetadataBase {
1158
1482
  Sequence=${fieldSequence},
1159
1483
  Type='${veField.Type}',
1160
1484
  AllowsNull=${this.boolLit(veField.AllowsNull)},
1161
- ${makePrimaryKey ? `IsPrimaryKey=${this.boolLit(true)},IsUnique=${this.boolLit(true)},` : ''}
1485
+ ${pkFlagsChanged ? `IsPrimaryKey=${this.boolLit(wantPrimaryKey)},IsUnique=${this.boolLit(wantUnique)},` : ''}
1162
1486
  Length=${veField.Length},
1163
1487
  Precision=${veField.Precision},
1164
1488
  Scale=${veField.Scale}
@@ -1182,9 +1506,9 @@ export class ManageMetadataBase {
1182
1506
  ${q('Length')}, ${q('Precision')}, ${q('Scale')},
1183
1507
  ${q('Sequence')}, ${q('IsPrimaryKey')}, ${q('IsUnique')},
1184
1508
  ${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
1185
- VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${veField.FieldName}', '${veField.Type}', ${this.boolLit(veField.AllowsNull)},
1509
+ VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${String(veField.FieldName).replace(/'/g, "''")}', '${veField.Type}', ${this.boolLit(veField.AllowsNull)},
1186
1510
  ${veField.Length}, ${veField.Precision}, ${veField.Scale},
1187
- ${safeSequence}, ${this.boolLit(makePrimaryKey)}, ${this.boolLit(makePrimaryKey)},
1511
+ ${safeSequence}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
1188
1512
  ${this.utcNow()}, ${this.utcNow()}
1189
1513
  )`;
1190
1514
  await this.LogSQLAndExecute(pool, sqlAdd, `SQL text to add virtual entity field ${veField.FieldName} for entity ${virtualEntity.Name}`);
@@ -1959,6 +2283,17 @@ export class ManageMetadataBase {
1959
2283
  const entities = entitiesResult.recordset;
1960
2284
  if (entities && entities.length > 0) {
1961
2285
  for (const e of entities) {
2286
+ // External-data-source entities have no physical MJ table by design, so they always
2287
+ // surface in vwEntitiesWithMissingBaseTables — but they must NOT be pruned (their data
2288
+ // lives on a remote system). Skip them here. This is the analogue of the VirtualEntity
2289
+ // exclusion, but kept as a single dialect-agnostic code guard rather than baked into the
2290
+ // per-dialect filter (getEntitiesWithMissingBaseTablesFilter) or the view, so it applies
2291
+ // uniformly across SQL Server and PostgreSQL. (The External Data Sources migration only
2292
+ // recreates the view to re-expose the ExternalDataSourceID column to this guard; it does
2293
+ // not exclude external entities at the SQL level.)
2294
+ if (e.ExternalDataSourceID) {
2295
+ continue;
2296
+ }
1962
2297
  // for the given entity, wipe out the entity metadata and its core deps.
1963
2298
  // the below could fail if there are non-core dependencies on the entity, but that's ok, we will flag that in the console
1964
2299
  // for the admin to handle manually
@@ -2442,12 +2777,19 @@ export class ManageMetadataBase {
2442
2777
  */
2443
2778
  async ensureCreatedAtUpdatedAtFieldsExist(pool, excludeSchemas) {
2444
2779
  try {
2780
+ // Only exclude external entities where the ExternalDataSourceID column exists (SQL Server today).
2781
+ // On PostgreSQL the column is absent, so referencing it here would throw and fail CodeGen; external
2782
+ // entities cannot exist there anyway, so the predicate is simply omitted.
2783
+ const externalEntityClause = (await this.entityHasExternalDataSourceColumn(pool))
2784
+ ? 'ExternalDataSourceID IS NULL AND -- external entities have no physical base table to add __mj_ system columns to (data is remote)'
2785
+ : '';
2445
2786
  const sqlEntities = `SELECT
2446
2787
  *
2447
2788
  FROM
2448
2789
  ${this.qs(mj_core_schema(), 'vwEntities')}
2449
2790
  WHERE
2450
2791
  VirtualEntity = ${this.boolLit(false)} AND
2792
+ ${externalEntityClause}
2451
2793
  TrackRecordChanges = ${this.boolLit(true)} AND
2452
2794
  SchemaName NOT IN (${excludeSchemas.map(s => `'${s}'`).join(',')})`;
2453
2795
  const entitiesResult = await this.runQuery(pool, sqlEntities);