@memberjunction/codegen-lib 5.32.0 → 5.34.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.
Files changed (70) hide show
  1. package/README.md +24 -0
  2. package/dist/Config/config.d.ts +19 -8
  3. package/dist/Config/config.d.ts.map +1 -1
  4. package/dist/Config/config.js +18 -8
  5. package/dist/Config/config.js.map +1 -1
  6. package/dist/Config/db-connection.d.ts.map +1 -1
  7. package/dist/Config/db-connection.js +8 -3
  8. package/dist/Config/db-connection.js.map +1 -1
  9. package/dist/Database/codeGenDatabaseProvider.d.ts +67 -0
  10. package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
  11. package/dist/Database/codeGenDatabaseProvider.js +82 -15
  12. package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
  13. package/dist/Database/manage-metadata.d.ts +112 -4
  14. package/dist/Database/manage-metadata.d.ts.map +1 -1
  15. package/dist/Database/manage-metadata.js +347 -43
  16. package/dist/Database/manage-metadata.js.map +1 -1
  17. package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.d.ts +4 -0
  18. package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.d.ts.map +1 -1
  19. package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.js +6 -0
  20. package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.js.map +1 -1
  21. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +122 -16
  22. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
  23. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +605 -49
  24. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
  25. package/dist/Database/providers/postgresql/viewFallback.d.ts +14 -0
  26. package/dist/Database/providers/postgresql/viewFallback.d.ts.map +1 -1
  27. package/dist/Database/providers/postgresql/viewFallback.js +107 -13
  28. package/dist/Database/providers/postgresql/viewFallback.js.map +1 -1
  29. package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.d.ts +4 -0
  30. package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.d.ts.map +1 -1
  31. package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.js +6 -0
  32. package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.js.map +1 -1
  33. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +5 -2
  34. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
  35. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +27 -7
  36. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
  37. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.test.js +101 -0
  38. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.test.js.map +1 -1
  39. package/dist/Database/sql.d.ts +8 -0
  40. package/dist/Database/sql.d.ts.map +1 -1
  41. package/dist/Database/sql.js +18 -14
  42. package/dist/Database/sql.js.map +1 -1
  43. package/dist/Database/sql_codegen.d.ts +19 -5
  44. package/dist/Database/sql_codegen.d.ts.map +1 -1
  45. package/dist/Database/sql_codegen.js +109 -55
  46. package/dist/Database/sql_codegen.js.map +1 -1
  47. package/dist/Misc/action_subclasses_codegen.js +1 -1
  48. package/dist/Misc/action_subclasses_codegen.js.map +1 -1
  49. package/dist/Misc/createNewUser.d.ts.map +1 -1
  50. package/dist/Misc/createNewUser.js +14 -3
  51. package/dist/Misc/createNewUser.js.map +1 -1
  52. package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
  53. package/dist/Misc/entity_subclasses_codegen.js +23 -17
  54. package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
  55. package/dist/Misc/graphql_server_codegen.d.ts +11 -0
  56. package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
  57. package/dist/Misc/graphql_server_codegen.js +27 -40
  58. package/dist/Misc/graphql_server_codegen.js.map +1 -1
  59. package/dist/Misc/sql_logging.d.ts.map +1 -1
  60. package/dist/Misc/sql_logging.js +22 -2
  61. package/dist/Misc/sql_logging.js.map +1 -1
  62. package/dist/Misc/system_integrity.js +3 -3
  63. package/dist/Misc/system_integrity.js.map +1 -1
  64. package/dist/Misc/temp_batch_file.js +2 -2
  65. package/dist/Misc/temp_batch_file.js.map +1 -1
  66. package/dist/runCodeGen.d.ts +1 -1
  67. package/dist/runCodeGen.d.ts.map +1 -1
  68. package/dist/runCodeGen.js +51 -6
  69. package/dist/runCodeGen.js.map +1 -1
  70. package/package.json +19 -18
@@ -1,6 +1,10 @@
1
1
  import { CodeGenDatabaseProvider } from './codeGenDatabaseProvider.js';
2
- import { SQLServerCodeGenProvider } from './providers/sqlserver/SQLServerCodeGenProvider.js';
3
- import { configInfo, currentWorkingDirectory, dbType, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
2
+ // Side-effect import — registers `SQLServerCodeGenProvider` with `MJGlobal.ClassFactory`
3
+ // under the `'sqlserver'` key via its `@RegisterClass` decorator. Without this import,
4
+ // `ClassFactory.CreateInstance(CodeGenDatabaseProvider, 'sqlserver')` returns nothing
5
+ // and the SS code path silently fails.
6
+ import './providers/sqlserver/SQLServerCodeGenProvider.js';
7
+ import { configInfo, currentWorkingDirectory, dbPlatform, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
4
8
  import { EntityInfo, ExtractActualDefaultValue, LogError, LogStatus, Metadata, SeverityType } from "@memberjunction/core";
5
9
  import { MJEntityFieldSchema } from "@memberjunction/core-entities";
6
10
  import { logError, logMessage, logStatus, startSpinner, updateSpinner, succeedSpinner } from "../Misc/status_logging.js";
@@ -36,31 +40,35 @@ export class ManageMetadataBase {
36
40
  constructor() {
37
41
  // ─── Database Provider Infrastructure ─────────────────────────────
38
42
  // All platform-specific SQL generation is delegated to the CodeGenDatabaseProvider.
39
- // The provider is lazily initialized from the dbType() config on first access.
43
+ // The provider is lazily initialized from the dbPlatform() config on first access.
40
44
  this._dbProvider = null;
41
45
  // ─── End Dialect Infrastructure ───────────────────────────────────
42
46
  this._sqlUtilityObject = MJGlobal.Instance.ClassFactory.CreateInstance(SQLUtilityBase);
43
47
  }
44
48
  /**
45
49
  * Returns the CodeGenDatabaseProvider for the current database platform.
46
- * Lazily initialized from dbType() configuration.
50
+ * Lazily initialized from dbPlatform() configuration.
51
+ *
52
+ * Lookup goes through `MJGlobal.ClassFactory` keyed by the platform string,
53
+ * which matches the `@RegisterClass(CodeGenDatabaseProvider, '<platform>')`
54
+ * decorators on the concrete providers (`'sqlserver'`, `'postgresql'`).
55
+ * Mismatched keys silently fall back to the abstract base class — fail loud
56
+ * with an explicit error so a misconfigured platform doesn't ship as a
57
+ * runtime breakage in dialect-specific methods.
47
58
  */
48
59
  get dbProvider() {
49
60
  if (!this._dbProvider) {
50
- const platform = dbType();
51
- if (platform === 'postgresql') {
52
- const pgProvider = MJGlobal.Instance.ClassFactory.CreateInstance(CodeGenDatabaseProvider, 'PostgreSQLCodeGenProvider');
53
- if (pgProvider) {
54
- this._dbProvider = pgProvider;
55
- }
56
- else {
57
- throw new Error('PostgreSQL CodeGen provider not found. Ensure @memberjunction/postgresql-dataprovider ' +
58
- 'is installed and its CodeGen provider is registered before running CodeGen.');
59
- }
60
- }
61
- else {
62
- this._dbProvider = new SQLServerCodeGenProvider();
63
- }
61
+ const platform = dbPlatform();
62
+ const provider = MJGlobal.Instance.ClassFactory.CreateInstance(CodeGenDatabaseProvider, platform);
63
+ // CreateInstance returns the abstract base class on a missed key (it
64
+ // doesn't throw), so disambiguate by constructor identity: anything
65
+ // that's still the bare base means lookup didn't resolve to a real
66
+ // dialect provider for this platform.
67
+ if (!provider || provider.constructor === CodeGenDatabaseProvider) {
68
+ throw new Error(`CodeGen provider for dbPlatform='${platform}' not found. Ensure the corresponding ` +
69
+ `provider package is installed and registered via @RegisterClass(CodeGenDatabaseProvider, '${platform}').`);
70
+ }
71
+ this._dbProvider = provider;
64
72
  }
65
73
  return this._dbProvider;
66
74
  }
@@ -117,6 +125,49 @@ export class ManageMetadataBase {
117
125
  async runQueryWithParams(pool, sql, params) {
118
126
  return pool.queryWithParams(this.qsql(sql), params);
119
127
  }
128
+ /**
129
+ * Returns a sequence value safe to use for an EntityField INSERT under the given
130
+ * EntityID, defaulting to `candidate` when it doesn't collide. Otherwise returns
131
+ * `MAX(Sequence) + 1` for that entity.
132
+ *
133
+ * Why this exists: several insert paths (virtual-entity field sync, IS-A parent
134
+ * field sync, schema-derived field creation) compute a deterministic sequence
135
+ * up-front, but a partial prior run can leave rows at that exact ordinal under
136
+ * the target EntityID. SS hides the collision because retried runs typically
137
+ * succeed before failing; PG raises UQ_EntityField_EntityID_Sequence (~4 errors
138
+ * per advanced-generation run). The values are temporary anyway —
139
+ * spUpdateExistingEntityFieldsFromSchema renumbers them on the next pass.
140
+ */
141
+ async nextAvailableEntityFieldSequence(pool, entityId, candidate) {
142
+ const schema = mj_core_schema();
143
+ const tbl = this.qs(schema, 'EntityField');
144
+ const seqCol = this.qi('Sequence');
145
+ const entityIdCol = this.qi('EntityID');
146
+ const sql = `SELECT
147
+ ${this.coalesce(`MAX(${seqCol})`, '0')} AS ${this.qi('MaxSeq')},
148
+ ${this.coalesce(`MAX(CASE WHEN ${seqCol} = @Candidate THEN 1 ELSE 0 END)`, '0')} AS ${this.qi('Hit')}
149
+ FROM ${tbl}
150
+ WHERE ${entityIdCol} = @EntityID`;
151
+ try {
152
+ const result = await this.runQueryWithParams(pool, sql, {
153
+ EntityID: entityId,
154
+ Candidate: candidate,
155
+ });
156
+ const row = result.recordset?.[0];
157
+ if (!row)
158
+ return candidate;
159
+ const hit = Number(row.Hit ?? 0) > 0;
160
+ if (!hit)
161
+ return candidate;
162
+ const maxSeq = Number(row.MaxSeq ?? 0);
163
+ return maxSeq + 1;
164
+ }
165
+ catch {
166
+ // If the lookup itself fails, fall back to the candidate. The downstream
167
+ // INSERT will surface the real error if there's a true collision.
168
+ return candidate;
169
+ }
170
+ }
120
171
  /**
121
172
  * Wraps a SELECT query with a row limit.
122
173
  * SQL Server: SELECT TOP N ... , PostgreSQL: SELECT ... LIMIT N
@@ -374,6 +425,49 @@ export class ManageMetadataBase {
374
425
  }
375
426
  return results;
376
427
  }
428
+ /**
429
+ * Extracts value-list field configurations from the additionalSchemaInfo config file.
430
+ * Walks schema → tables → table.Fields[] and returns a map keyed by "schema.table".
431
+ */
432
+ extractFieldValueLists(config) {
433
+ const results = new Map();
434
+ // Check flat "Tables" array format
435
+ if (Array.isArray(config.Tables)) {
436
+ for (const table of config.Tables) {
437
+ this.extractFieldsFromTableEntry(table.SchemaName || 'dbo', table.TableName, table, results);
438
+ }
439
+ return results;
440
+ }
441
+ // Schema-as-key format
442
+ const metadataKeys = new Set(['$schema', 'description', 'version', 'VirtualEntities', 'ISARelationships', 'Entities', 'Tables', 'Schemas']);
443
+ for (const [key, value] of Object.entries(config)) {
444
+ if (metadataKeys.has(key))
445
+ continue;
446
+ if (!Array.isArray(value))
447
+ continue;
448
+ for (const table of value) {
449
+ this.extractFieldsFromTableEntry(key, table.TableName, table, results);
450
+ }
451
+ }
452
+ return results;
453
+ }
454
+ /**
455
+ * Extracts Fields[] entries from a single table config entry and adds them to the results map.
456
+ */
457
+ extractFieldsFromTableEntry(schemaName, tableName, tableConfig, results) {
458
+ const fields = tableConfig.Fields;
459
+ if (!Array.isArray(fields) || fields.length === 0)
460
+ return;
461
+ const key = `${schemaName}.${tableName}`;
462
+ const fieldConfigs = fields.map((f) => ({
463
+ FieldName: f.FieldName,
464
+ ValueListType: f.ValueListType || 'List',
465
+ PossibleValues: f.PossibleValues || [],
466
+ Description: f.Description || undefined,
467
+ Confidence: f.Confidence || undefined,
468
+ }));
469
+ results.set(key, fieldConfigs);
470
+ }
377
471
  /**
378
472
  * Processes organic key configurations from additionalSchemaInfo.
379
473
  * For each configured organic key:
@@ -954,6 +1048,11 @@ export class ManageMetadataBase {
954
1048
  else {
955
1049
  // this means that we do NOT have a match so the field does not exist in the entity definition, so we need to add it
956
1050
  newEntityFieldUUID = this.createNewUUID();
1051
+ // Compute a non-colliding sequence by querying MAX(Sequence) for this entity. The
1052
+ // ordinal `fieldSequence` (column index in the view) is correct for stable ordering
1053
+ // when nothing exists yet, but a partial prior run could have left rows at the same
1054
+ // ordinal under this EntityID — UQ_EntityField_EntityID_Sequence then fires on PG.
1055
+ const safeSequence = await this.nextAvailableEntityFieldSequence(pool, entity.ID, fieldSequence);
957
1056
  const q = (n) => this.qi(n);
958
1057
  const sqlAdd = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
959
1058
  ${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
@@ -962,7 +1061,7 @@ export class ManageMetadataBase {
962
1061
  ${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
963
1062
  VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${veField.FieldName}', '${veField.Type}', ${this.boolLit(veField.AllowsNull)},
964
1063
  ${veField.Length}, ${veField.Precision}, ${veField.Scale},
965
- ${fieldSequence}, ${this.boolLit(makePrimaryKey)}, ${this.boolLit(makePrimaryKey)},
1064
+ ${safeSequence}, ${this.boolLit(makePrimaryKey)}, ${this.boolLit(makePrimaryKey)},
966
1065
  ${this.utcNow()}, ${this.utcNow()}
967
1066
  )`;
968
1067
  await this.LogSQLAndExecute(pool, sqlAdd, `SQL text to add virtual entity field ${veField.FieldName} for entity ${virtualEntity.Name}`);
@@ -1187,7 +1286,7 @@ export class ManageMetadataBase {
1187
1286
  WHERE EntityID='${entity.ID}' AND Name='${pk}'`);
1188
1287
  logStatus(` ✓ Set PK for ${entity.Name}.${pk} (LLM-identified)`);
1189
1288
  }
1190
- await this.LogSQLAndExecute(pool, sqlStatements.join('\n'), `Set LLM-identified PKs for ${entity.Name}: ${validPKs.join(', ')}`);
1289
+ await this.LogSQLBatchAndExecute(pool, sqlStatements, `Set LLM-identified PKs for ${entity.Name}: ${validPKs.join(', ')}`);
1191
1290
  return true;
1192
1291
  }
1193
1292
  /**
@@ -1232,7 +1331,7 @@ export class ManageMetadataBase {
1232
1331
  if (sqlStatements.length === 0) {
1233
1332
  return false;
1234
1333
  }
1235
- await this.LogSQLAndExecute(pool, sqlStatements.join('\n'), `Set LLM-identified FKs for ${entity.Name}`);
1334
+ await this.LogSQLBatchAndExecute(pool, sqlStatements, `Set LLM-identified FKs for ${entity.Name}`);
1236
1335
  return true;
1237
1336
  }
1238
1337
  /**
@@ -1269,7 +1368,7 @@ export class ManageMetadataBase {
1269
1368
  if (sqlStatements.length === 0) {
1270
1369
  return false;
1271
1370
  }
1272
- await this.LogSQLAndExecute(pool, sqlStatements.join('\n'), `Set LLM-generated descriptions for ${entity.Name} (${sqlStatements.length} fields)`);
1371
+ await this.LogSQLBatchAndExecute(pool, sqlStatements, `Set LLM-generated descriptions for ${entity.Name} (${sqlStatements.length} fields)`);
1273
1372
  return true;
1274
1373
  }
1275
1374
  /**
@@ -1412,8 +1511,12 @@ export class ManageMetadataBase {
1412
1511
  else {
1413
1512
  // Create new virtual field record for this parent field
1414
1513
  const newFieldID = this.createNewUUID();
1415
- // Use high sequence — will be reordered by updateExistingEntityFieldsFromSchema
1416
- const sequence = 100000 + parentFields.indexOf(parentField);
1514
+ // Use high sequence — will be reordered by updateExistingEntityFieldsFromSchema.
1515
+ // Query MAX(Sequence) at insert time so we never collide with an existing row
1516
+ // (e.g. partial prior run left a record at the candidate ordinal under this
1517
+ // EntityID — UQ_EntityField_EntityID_Sequence then fires on PG).
1518
+ const candidate = 100000 + parentFields.indexOf(parentField);
1519
+ const sequence = await this.nextAvailableEntityFieldSequence(pool, childEntity.ID, candidate);
1417
1520
  const q = (n) => this.qi(n);
1418
1521
  const sqlInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
1419
1522
  ${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
@@ -1948,6 +2051,14 @@ export class ManageMetadataBase {
1948
2051
  bSuccess = false;
1949
2052
  }
1950
2053
  logStatus(` Managed entity field values in ${(new Date().getTime() - step6StartTime.getTime()) / 1000} seconds`);
2054
+ // Apply soft value-list config from additionalSchemaInfo Fields[]
2055
+ // Runs AFTER CHECK constraint pass so CHECK-derived values are authoritative
2056
+ const stepValueListStartTime = new Date();
2057
+ if (!await this.applyValueListConfig(pool)) {
2058
+ logError('Error applying soft value-list configuration');
2059
+ // Non-fatal: soft value-lists are supplementary
2060
+ }
2061
+ logStatus(` Applied soft value-list configuration in ${(new Date().getTime() - stepValueListStartTime.getTime()) / 1000} seconds`);
1951
2062
  }
1952
2063
  // Advanced Generation - Smart field identification and form layout
1953
2064
  if (!skipAdvancedGeneration) {
@@ -2098,6 +2209,98 @@ export class ManageMetadataBase {
2098
2209
  return false;
2099
2210
  }
2100
2211
  }
2212
+ /**
2213
+ * Applies soft value-list configuration from Fields[] entries in additionalSchemaInfo.json.
2214
+ * Runs AFTER manageEntityFieldValuesAndValidatorFunctions() so CHECK-constraint-derived
2215
+ * values are already in the database. If EntityFieldValue rows already exist for a field
2216
+ * (from CHECK constraints), the soft-enum pass skips that field — CHECK is authoritative.
2217
+ */
2218
+ async applyValueListConfig(pool) {
2219
+ const config = ManageMetadataBase.getSoftPKFKConfig();
2220
+ if (!config)
2221
+ return true;
2222
+ const fieldLists = this.extractFieldValueLists(config);
2223
+ if (fieldLists.size === 0)
2224
+ return true;
2225
+ try {
2226
+ // Load all existing EntityFieldValue rows for dedup checking
2227
+ const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence`;
2228
+ const allEFVResult = await this.runQuery(pool, efvSQL);
2229
+ const allEntityFieldValues = allEFVResult.recordset;
2230
+ let totalApplied = 0;
2231
+ let totalSkipped = 0;
2232
+ for (const [schemaTable, fields] of fieldLists) {
2233
+ const [schemaName, tableName] = schemaTable.split('.');
2234
+ const result = await this.applyValueListsForTable(pool, schemaName, tableName, fields, allEntityFieldValues);
2235
+ totalApplied += result.applied;
2236
+ totalSkipped += result.skipped;
2237
+ }
2238
+ if (totalApplied > 0 || totalSkipped > 0) {
2239
+ logStatus(` Applied ${totalApplied} soft value-list(s), skipped ${totalSkipped} (CHECK-authoritative)`);
2240
+ }
2241
+ return true;
2242
+ }
2243
+ catch (e) {
2244
+ logError(`Error applying value-list configuration: ${e}`);
2245
+ return false;
2246
+ }
2247
+ }
2248
+ /**
2249
+ * Applies value-list fields for a single table. For each field, checks if
2250
+ * EntityFieldValue rows already exist (from CHECK constraints) and skips if so.
2251
+ */
2252
+ async applyValueListsForTable(pool, schemaName, tableName, fields, allEntityFieldValues) {
2253
+ const schema = mj_core_schema();
2254
+ let applied = 0;
2255
+ let skipped = 0;
2256
+ // Look up entity ID
2257
+ const entityLookupSQL = `SELECT ID FROM ${this.qs(schema, 'Entity')} WHERE SchemaName = '${schemaName}' AND BaseTable = '${tableName}'`;
2258
+ const entityResult = await this.runQuery(pool, entityLookupSQL);
2259
+ if (entityResult.recordset.length === 0) {
2260
+ logStatus(` ⚠️ Entity not found for ${schemaName}.${tableName} — skipping soft value-lists`);
2261
+ return { applied, skipped };
2262
+ }
2263
+ const entityId = entityResult.recordset[0].ID;
2264
+ for (const fieldCfg of fields) {
2265
+ const fieldResult = await this.applyValueListForField(pool, schema, entityId, schemaName, tableName, fieldCfg, allEntityFieldValues);
2266
+ if (fieldResult === 'applied')
2267
+ applied++;
2268
+ else
2269
+ skipped++;
2270
+ }
2271
+ return { applied, skipped };
2272
+ }
2273
+ /**
2274
+ * Applies value-list config for a single field. Returns 'applied' or 'skipped'.
2275
+ */
2276
+ async applyValueListForField(pool, schema, entityId, schemaName, tableName, fieldCfg, allEntityFieldValues) {
2277
+ // Look up EntityField ID
2278
+ const efSQL = `SELECT ID, ValueListType FROM ${this.qs(schema, 'EntityField')} WHERE EntityID = '${entityId}' AND Name = '${fieldCfg.FieldName}'`;
2279
+ const efResult = await this.runQuery(pool, efSQL);
2280
+ if (efResult.recordset.length === 0) {
2281
+ logStatus(` ⚠️ Field ${fieldCfg.FieldName} not found on ${schemaName}.${tableName} — skipping`);
2282
+ return 'skipped';
2283
+ }
2284
+ const efRecord = efResult.recordset[0];
2285
+ const entityFieldID = efRecord.ID;
2286
+ // CHECK constraint is authoritative — if EntityFieldValue rows already exist, skip
2287
+ const existing = allEntityFieldValues.filter((efv) => String(efv.EntityFieldID) === String(entityFieldID));
2288
+ const currentValueListType = (efRecord.ValueListType || '').trim().toLowerCase();
2289
+ if (existing.length > 0 && currentValueListType !== 'none' && currentValueListType !== '') {
2290
+ return 'skipped';
2291
+ }
2292
+ // Apply: sync values and set ValueListType
2293
+ const sortedValues = [...fieldCfg.PossibleValues].sort();
2294
+ await this.syncEntityFieldValues(pool, entityFieldID, sortedValues, allEntityFieldValues);
2295
+ // Update ValueListType
2296
+ const newType = fieldCfg.ValueListType;
2297
+ if (currentValueListType !== newType.toLowerCase()) {
2298
+ const updateSQL = `UPDATE ${this.qs(schema, 'EntityField')} SET ValueListType='${newType}' WHERE ID='${entityFieldID}'`;
2299
+ await this.LogSQLAndExecute(pool, updateSQL, `Set soft ValueListType='${newType}' for ${schemaName}.${tableName}.${fieldCfg.FieldName}`);
2300
+ }
2301
+ logStatus(` ✓ Applied soft value-list for ${tableName}.${fieldCfg.FieldName} (${sortedValues.length} values, type=${newType})`);
2302
+ return 'applied';
2303
+ }
2101
2304
  /**
2102
2305
  * This method ensures that the __mj_CreatedAt and __mj_UpdatedAt fields exist in each entity that has TrackRecordChanges set to true. If the fields do not exist, they are created.
2103
2306
  * If the fields exist but have incorrect default values, the default values are updated. The default value that is to be used for these special fields is GETUTCDATE() which is the
@@ -2408,6 +2611,7 @@ export class ManageMetadataBase {
2408
2611
  ${this.qi('AutoIncrement')},
2409
2612
  ${this.qi('AllowUpdateAPI')},
2410
2613
  ${this.qi('IsVirtual')},
2614
+ ${this.qi('IsComputed')},
2411
2615
  ${this.qi('RelatedEntityID')},
2412
2616
  ${this.qi('RelatedEntityFieldName')},
2413
2617
  ${this.qi('IsNameField')},
@@ -2437,6 +2641,7 @@ export class ManageMetadataBase {
2437
2641
  ${this.boolLit(n.AutoIncrement)},
2438
2642
  ${this.boolLit(n.AllowUpdateAPI)},
2439
2643
  ${this.boolLit(n.IsVirtual)},
2644
+ ${this.boolLit(n.IsComputed)},
2440
2645
  ${n.RelatedEntityID && n.RelatedEntityID.length > 0 ? `'${n.RelatedEntityID}'` : 'NULL'},
2441
2646
  ${n.RelatedEntityFieldName && n.RelatedEntityFieldName.length > 0 ? `'${n.RelatedEntityFieldName}'` : 'NULL'},
2442
2647
  ${this.boolLit(n.IsNameField !== null ? n.IsNameField : false)},
@@ -3806,6 +4011,8 @@ export class ManageMetadataBase {
3806
4011
  if (fieldAnalysis) {
3807
4012
  await this.applySmartFieldIdentification(pool, {
3808
4013
  ID: entity.ID,
4014
+ Name: entity.Name,
4015
+ SchemaName: entityRecord.SchemaName,
3809
4016
  AllowUserSearchAPI: entityRecord.AllowUserSearchAPI,
3810
4017
  AutoUpdateAllowUserSearchAPI: entityRecord.AutoUpdateAllowUserSearchAPI,
3811
4018
  FullTextSearchEnabled: entityRecord.FullTextSearchEnabled,
@@ -3905,7 +4112,7 @@ export class ManageMetadataBase {
3905
4112
  const sqlStatements = [];
3906
4113
  this.applyNameFieldUpdates(sqlStatements, fields, result);
3907
4114
  this.applyDefaultInViewUpdates(sqlStatements, fields, result);
3908
- this.applySearchableFieldUpdates(sqlStatements, fields, result);
4115
+ this.applySearchableFieldUpdates(sqlStatements, fields, result, entity);
3909
4116
  this.applySearchPredicateUpdates(sqlStatements, fields, result);
3910
4117
  this.applyEntitySearchConfig(sqlStatements, entity, result);
3911
4118
  if (configInfo.advancedGeneration?.allowFullTextSearchAutoUpdate) {
@@ -3913,9 +4120,8 @@ export class ManageMetadataBase {
3913
4120
  }
3914
4121
  // Execute all updates in one batch
3915
4122
  if (sqlStatements.length > 0) {
3916
- const combinedSQL = sqlStatements.join('\n');
3917
4123
  try {
3918
- await this.LogSQLAndExecute(pool, combinedSQL, `Set field properties for entity`, false);
4124
+ await this.LogSQLBatchAndExecute(pool, sqlStatements, `Set field properties for entity`, false);
3919
4125
  }
3920
4126
  catch (ex) {
3921
4127
  logError('Error executing combined smart field SQL: ', ex);
@@ -3963,9 +4169,17 @@ export class ManageMetadataBase {
3963
4169
  }
3964
4170
  }
3965
4171
  /**
3966
- * Generate SQL UPDATEs for IncludeInUserSearchAPI on the identified searchable fields
4172
+ * Generate SQL UPDATEs for IncludeInUserSearchAPI on the identified searchable fields.
4173
+ *
4174
+ * Guardrails: even if the SmartFieldIdentificationResult flags a field as searchable,
4175
+ * we refuse to enable IncludeInUserSearchAPI when the field is a primary key, a
4176
+ * non-text type (LIKE forces an implicit per-row CONVERT and never seeks an index),
4177
+ * or an unbounded text type whose parent entity is not FTX-enabled (the LIKE path
4178
+ * cannot seek MAX/ntext/text columns; FTX is the right tool there). These are
4179
+ * always-wrong cases — they cause the data provider to emit unindexed scans on
4180
+ * every search.
3967
4181
  */
3968
- applySearchableFieldUpdates(sqlStatements, fields, result) {
4182
+ applySearchableFieldUpdates(sqlStatements, fields, result, entity) {
3969
4183
  if (!result.searchableFields || result.searchableFields.length === 0) {
3970
4184
  return;
3971
4185
  }
@@ -3974,7 +4188,13 @@ export class ManageMetadataBase {
3974
4188
  if (missingSearchableFields.length > 0) {
3975
4189
  logError(`Smart field identification returned invalid searchableFields: ${missingSearchableFields.join(', ')} not found in entity`);
3976
4190
  }
4191
+ const ftxEnabled = !!entity?.FullTextSearchEnabled;
3977
4192
  for (const field of searchableFields) {
4193
+ if (!this.isFieldEligibleForUserSearch(field, ftxEnabled)) {
4194
+ // Silently skip — the LLM proposed a field we cannot honor without
4195
+ // creating an unindexed scan. This is a guardrail, not a bug.
4196
+ continue;
4197
+ }
3978
4198
  if (!field.IncludeInUserSearchAPI) {
3979
4199
  sqlStatements.push(`
3980
4200
  UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
@@ -3985,6 +4205,25 @@ export class ManageMetadataBase {
3985
4205
  }
3986
4206
  }
3987
4207
  }
4208
+ /**
4209
+ * Returns true if the field is a sensible target for LIKE-based user search.
4210
+ * Mirrors the runtime guard in GenericDatabaseProvider.isTextSearchableType /
4211
+ * the Phase 1 hygiene migration so CodeGen stops re-introducing invalid flags.
4212
+ */
4213
+ isFieldEligibleForUserSearch(field, ftxEnabled) {
4214
+ if (field.IsPrimaryKey === true)
4215
+ return false;
4216
+ const type = (field.Type ?? '').toLowerCase();
4217
+ // Non-text types are never appropriate for substring search.
4218
+ const textTypes = new Set(['nvarchar', 'varchar', 'char', 'nchar']);
4219
+ if (!textTypes.has(type))
4220
+ return false;
4221
+ // Unbounded text on a non-FTX entity cannot be index-seeked.
4222
+ const length = field.Length;
4223
+ if (length === -1 && !ftxEnabled)
4224
+ return false;
4225
+ return true;
4226
+ }
3988
4227
  /**
3989
4228
  * Generate SQL UPDATEs for UserSearchPredicateAPI on each searchable field
3990
4229
  */
@@ -4013,7 +4252,14 @@ export class ManageMetadataBase {
4013
4252
  }
4014
4253
  }
4015
4254
  /**
4016
- * Generate SQL UPDATEs for entity-level AllowUserSearchAPI
4255
+ * Generate SQL UPDATEs for entity-level AllowUserSearchAPI.
4256
+ *
4257
+ * Guardrail: even if the LLM proposes enabling AllowUserSearchAPI on an entity
4258
+ * that looks like an audit/log/run-history table by name, refuse — those tables
4259
+ * grow unboundedly, the LIKE fan-out across them dominates global-search latency,
4260
+ * and they're virtually never the right target for a user-facing search box.
4261
+ * The team can still enable search on such an entity by setting
4262
+ * AutoUpdateAllowUserSearchAPI=0 and flipping the flag manually.
4017
4263
  */
4018
4264
  applyEntitySearchConfig(sqlStatements, entity, result) {
4019
4265
  if (result.allowUserSearch == null || !entity.AutoUpdateAllowUserSearchAPI) {
@@ -4021,6 +4267,11 @@ export class ManageMetadataBase {
4021
4267
  }
4022
4268
  const newValue = result.allowUserSearch;
4023
4269
  const currentValue = !!entity.AllowUserSearchAPI;
4270
+ // If the LLM is proposing to ENABLE search on a log/audit/run-history-style
4271
+ // entity, drop the proposal. We never block a proposal to DISABLE search.
4272
+ if (newValue && !currentValue && this.isLikelyLogOrAuditEntity(entity.Name, entity.SchemaName)) {
4273
+ return;
4274
+ }
4024
4275
  if (newValue !== currentValue) {
4025
4276
  sqlStatements.push(`
4026
4277
  UPDATE ${this.qs(mj_core_schema(), 'Entity')}
@@ -4030,6 +4281,31 @@ export class ManageMetadataBase {
4030
4281
  `);
4031
4282
  }
4032
4283
  }
4284
+ /**
4285
+ * Heuristic: does the entity name match the shape of an audit / log /
4286
+ * run-history / change-tracking table? These are the entities that drive
4287
+ * the most LIKE-scan time and almost never belong in global search.
4288
+ */
4289
+ isLikelyLogOrAuditEntity(name, _schemaName) {
4290
+ if (!name)
4291
+ return false;
4292
+ const n = name.trim();
4293
+ // Common patterns: ends in "Logs"/"Log", ends in "Runs"/"Run History",
4294
+ // contains "Audit", or matches MJ's "Record Changes" naming.
4295
+ if (/\bLogs?$/i.test(n))
4296
+ return true;
4297
+ if (/\bAudit\b/i.test(n))
4298
+ return true;
4299
+ if (/\bRecord Changes?$/i.test(n))
4300
+ return true;
4301
+ if (/\bRuns?$/i.test(n) && !/Test Runs?$/i.test(n))
4302
+ return true;
4303
+ if (/\bRun (History|Steps|Messages)$/i.test(n))
4304
+ return true;
4305
+ if (/\bExecution Logs?$/i.test(n))
4306
+ return true;
4307
+ return false;
4308
+ }
4033
4309
  /**
4034
4310
  * Generate SQL UPDATEs for entity-level and field-level FullTextSearch configuration
4035
4311
  */
@@ -4238,7 +4514,7 @@ WHERE
4238
4514
  }
4239
4515
  if (sqlStatements.length > 0) {
4240
4516
  try {
4241
- await this.LogSQLAndExecute(pool, sqlStatements.join('\n'), `Set categories for ${sqlStatements.length} fields`, false);
4517
+ await this.LogSQLBatchAndExecute(pool, sqlStatements, `Set categories for ${sqlStatements.length} fields`, false);
4242
4518
  }
4243
4519
  catch (ex) {
4244
4520
  logError('Error Applying Field Categories', ex);
@@ -4259,8 +4535,8 @@ WHERE
4259
4535
  const escapedIcon = entityIcon.replace(/'/g, "''");
4260
4536
  const updateSQL = `
4261
4537
  UPDATE ${this.qs(mj_core_schema(), 'Entity')}
4262
- SET Icon = '${escapedIcon}', __mj_UpdatedAt = ${this.utcNow()}
4263
- WHERE ID = '${entityId}'
4538
+ SET ${this.qi('Icon')} = '${escapedIcon}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
4539
+ WHERE ${this.qi('ID')} = '${entityId}'
4264
4540
  `;
4265
4541
  try {
4266
4542
  await this.LogSQLAndExecute(pool, updateSQL, `Set entity icon to ${entityIcon}`, false);
@@ -4280,14 +4556,14 @@ WHERE
4280
4556
  return;
4281
4557
  const infoJSON = JSON.stringify(categoryInfo).replace(/'/g, "''");
4282
4558
  // Upsert FieldCategoryInfo (new format)
4283
- const checkNewSQL = `SELECT ID FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryInfo'`;
4559
+ const checkNewSQL = `SELECT ${this.qi('ID')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
4284
4560
  const existingNew = await this.runQuery(pool, checkNewSQL);
4285
4561
  if (existingNew.recordset.length > 0) {
4286
4562
  try {
4287
4563
  await this.LogSQLAndExecute(pool, `
4288
4564
  UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
4289
- SET Value = '${infoJSON}', __mj_UpdatedAt = ${this.utcNow()}
4290
- WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryInfo'
4565
+ SET ${this.qi('Value')} = '${infoJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
4566
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
4291
4567
  `, `Update FieldCategoryInfo setting for entity`, false);
4292
4568
  }
4293
4569
  catch (ex) {
@@ -4298,7 +4574,7 @@ WHERE
4298
4574
  const newId = uuidv4();
4299
4575
  try {
4300
4576
  await this.LogSQLAndExecute(pool, `
4301
- INSERT INTO ${this.qs(mj_core_schema(), 'EntitySetting')} (ID, EntityID, Name, Value, __mj_CreatedAt, __mj_UpdatedAt)
4577
+ INSERT INTO ${this.qs(mj_core_schema(), 'EntitySetting')} (${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Name')}, ${this.qi('Value')}, ${this.qi(EntityInfo.CreatedAtFieldName)}, ${this.qi(EntityInfo.UpdatedAtFieldName)})
4302
4578
  VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})
4303
4579
  `, `Insert FieldCategoryInfo setting for entity`, false);
4304
4580
  }
@@ -4314,14 +4590,14 @@ WHERE
4314
4590
  }
4315
4591
  }
4316
4592
  const iconsJSON = JSON.stringify(iconsOnly).replace(/'/g, "''");
4317
- const checkLegacySQL = `SELECT ID FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryIcons'`;
4593
+ const checkLegacySQL = `SELECT ${this.qi('ID')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
4318
4594
  const existingLegacy = await this.runQuery(pool, checkLegacySQL);
4319
4595
  if (existingLegacy.recordset.length > 0) {
4320
4596
  try {
4321
4597
  await this.LogSQLAndExecute(pool, `
4322
4598
  UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
4323
- SET Value = '${iconsJSON}', __mj_UpdatedAt = ${this.utcNow()}
4324
- WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryIcons'
4599
+ SET ${this.qi('Value')} = '${iconsJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
4600
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
4325
4601
  `, `Update FieldCategoryIcons setting (legacy)`, false);
4326
4602
  }
4327
4603
  catch (ex) {
@@ -4332,7 +4608,7 @@ WHERE
4332
4608
  const newId = uuidv4();
4333
4609
  try {
4334
4610
  await this.LogSQLAndExecute(pool, `
4335
- INSERT INTO ${this.qs(mj_core_schema(), 'EntitySetting')} (ID, EntityID, Name, Value, __mj_CreatedAt, __mj_UpdatedAt)
4611
+ INSERT INTO ${this.qs(mj_core_schema(), 'EntitySetting')} (${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Name')}, ${this.qi('Value')}, ${this.qi(EntityInfo.CreatedAtFieldName)}, ${this.qi(EntityInfo.UpdatedAtFieldName)})
4336
4612
  VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})
4337
4613
  `, `Insert FieldCategoryIcons setting (legacy)`, false);
4338
4614
  }
@@ -4348,8 +4624,8 @@ WHERE
4348
4624
  async applyEntityImportance(pool, entityId, importance) {
4349
4625
  const updateSQL = `
4350
4626
  UPDATE ${this.qs(mj_core_schema(), 'ApplicationEntity')}
4351
- SET DefaultForNewUser = ${this.boolLit(importance.defaultForNewUser)}, __mj_UpdatedAt = ${this.utcNow()}
4352
- WHERE EntityID = '${entityId}'
4627
+ SET ${this.qi('DefaultForNewUser')} = ${this.boolLit(importance.defaultForNewUser)}, ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
4628
+ WHERE ${this.qi('EntityID')} = '${entityId}'
4353
4629
  `;
4354
4630
  try {
4355
4631
  await this.LogSQLAndExecute(pool, updateSQL, `Set DefaultForNewUser=${importance.defaultForNewUser} for NEW entity (category: ${importance.entityCategory}, confidence: ${importance.confidence})`, false);
@@ -4373,5 +4649,33 @@ WHERE
4373
4649
  async LogSQLAndExecute(pool, query, description, isRecurringScript = false, includeBatchSeparator = false, batchSeparator = 'GO') {
4374
4650
  return await SQLLogging.LogSQLAndExecute(pool, this.qsql(query), description, isRecurringScript, includeBatchSeparator, batchSeparator);
4375
4651
  }
4652
+ /**
4653
+ * Logs and executes a sequence of SQL statements as a single batch.
4654
+ *
4655
+ * Each statement is dialect-quoted (via `qsql`), has its trailing
4656
+ * whitespace/`;` trimmed, then gets a single `;` re-appended. Statements
4657
+ * are joined with `\n` and sent through `LogSQLAndExecute` so PG's strict
4658
+ * parser sees properly-terminated boundaries between statements.
4659
+ *
4660
+ * Use this instead of `statements.join('\n')` followed by
4661
+ * `LogSQLAndExecute(...)`. SQL Server tolerated the unterminated form;
4662
+ * PG does not. SS still accepts the explicitly-terminated batch, so
4663
+ * this is a no-cost upgrade for both dialects.
4664
+ *
4665
+ * Empty / whitespace-only statements are filtered out. Returns
4666
+ * `undefined` (no execution) when nothing remains.
4667
+ */
4668
+ async LogSQLBatchAndExecute(pool, statements, description, isRecurringScript = false, includeBatchSeparator = false, batchSeparator = 'GO') {
4669
+ const terminated = [];
4670
+ for (const s of statements) {
4671
+ const trimmed = (s ?? '').replace(/[\s;]+$/g, '');
4672
+ if (trimmed.length === 0)
4673
+ continue;
4674
+ terminated.push(`${trimmed};`);
4675
+ }
4676
+ if (terminated.length === 0)
4677
+ return undefined;
4678
+ return await this.LogSQLAndExecute(pool, terminated.join('\n'), description, isRecurringScript, includeBatchSeparator, batchSeparator);
4679
+ }
4376
4680
  }
4377
4681
  //# sourceMappingURL=manage-metadata.js.map