@memberjunction/codegen-lib 6.1.0-edge.5 → 6.1.0-edge.6

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 (109) hide show
  1. package/README.md +20 -0
  2. package/dist/Angular/angular-codegen.d.ts +9 -6
  3. package/dist/Angular/angular-codegen.d.ts.map +1 -1
  4. package/dist/Angular/angular-codegen.js +80 -51
  5. package/dist/Angular/angular-codegen.js.map +1 -1
  6. package/dist/Angular/join-grid-related-entity-component.d.ts.map +1 -1
  7. package/dist/Angular/join-grid-related-entity-component.js +5 -2
  8. package/dist/Angular/join-grid-related-entity-component.js.map +1 -1
  9. package/dist/Angular/related-entity-components.d.ts +11 -0
  10. package/dist/Angular/related-entity-components.d.ts.map +1 -1
  11. package/dist/Angular/related-entity-components.js +23 -0
  12. package/dist/Angular/related-entity-components.js.map +1 -1
  13. package/dist/Angular/timeline-related-entity-component.d.ts.map +1 -1
  14. package/dist/Angular/timeline-related-entity-component.js +3 -2
  15. package/dist/Angular/timeline-related-entity-component.js.map +1 -1
  16. package/dist/Config/config.d.ts +44 -2
  17. package/dist/Config/config.d.ts.map +1 -1
  18. package/dist/Config/config.js +31 -5
  19. package/dist/Config/config.js.map +1 -1
  20. package/dist/Database/codeGenDatabaseProvider.d.ts +19 -1
  21. package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
  22. package/dist/Database/codeGenDatabaseProvider.js +37 -3
  23. package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
  24. package/dist/Database/dbSchema.js +2 -1
  25. package/dist/Database/dbSchema.js.map +1 -1
  26. package/dist/Database/decision-metadata-format.d.ts +124 -0
  27. package/dist/Database/decision-metadata-format.d.ts.map +1 -0
  28. package/dist/Database/decision-metadata-format.js +279 -0
  29. package/dist/Database/decision-metadata-format.js.map +1 -0
  30. package/dist/Database/decision-metadata-writer.d.ts +48 -0
  31. package/dist/Database/decision-metadata-writer.d.ts.map +1 -0
  32. package/dist/Database/decision-metadata-writer.js +482 -0
  33. package/dist/Database/decision-metadata-writer.js.map +1 -0
  34. package/dist/Database/entity-field-change-tracking.d.ts +53 -0
  35. package/dist/Database/entity-field-change-tracking.d.ts.map +1 -0
  36. package/dist/Database/entity-field-change-tracking.js +120 -0
  37. package/dist/Database/entity-field-change-tracking.js.map +1 -0
  38. package/dist/Database/entity-level-tree-cycles.d.ts.map +1 -1
  39. package/dist/Database/entity-level-tree-cycles.js +3 -2
  40. package/dist/Database/entity-level-tree-cycles.js.map +1 -1
  41. package/dist/Database/field-metadata-lock.d.ts +47 -0
  42. package/dist/Database/field-metadata-lock.d.ts.map +1 -0
  43. package/dist/Database/field-metadata-lock.js +159 -0
  44. package/dist/Database/field-metadata-lock.js.map +1 -0
  45. package/dist/Database/manage-metadata.d.ts +100 -50
  46. package/dist/Database/manage-metadata.d.ts.map +1 -1
  47. package/dist/Database/manage-metadata.js +549 -292
  48. package/dist/Database/manage-metadata.js.map +1 -1
  49. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +2 -2
  50. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
  51. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +90 -41
  52. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
  53. package/dist/Database/providers/postgresql/metadataSupportObjects.js +31 -8
  54. package/dist/Database/providers/postgresql/metadataSupportObjects.js.map +1 -1
  55. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +1 -1
  56. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
  57. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +48 -23
  58. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
  59. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js +15 -2
  60. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js.map +1 -1
  61. package/dist/Database/sql_codegen.d.ts +9 -0
  62. package/dist/Database/sql_codegen.d.ts.map +1 -1
  63. package/dist/Database/sql_codegen.js +53 -14
  64. package/dist/Database/sql_codegen.js.map +1 -1
  65. package/dist/Manifest/GenerateClassRegistrationsManifest.d.ts.map +1 -1
  66. package/dist/Manifest/GenerateClassRegistrationsManifest.js +3 -2
  67. package/dist/Manifest/GenerateClassRegistrationsManifest.js.map +1 -1
  68. package/dist/Misc/action_subclasses_codegen.js +2 -2
  69. package/dist/Misc/action_subclasses_codegen.js.map +1 -1
  70. package/dist/Misc/advanced_generation.d.ts +7 -5
  71. package/dist/Misc/advanced_generation.d.ts.map +1 -1
  72. package/dist/Misc/advanced_generation.js +34 -23
  73. package/dist/Misc/advanced_generation.js.map +1 -1
  74. package/dist/Misc/codegen-reporter.d.ts +2 -0
  75. package/dist/Misc/codegen-reporter.d.ts.map +1 -1
  76. package/dist/Misc/codegen-reporter.js +6 -2
  77. package/dist/Misc/codegen-reporter.js.map +1 -1
  78. package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
  79. package/dist/Misc/entity_subclasses_codegen.js +30 -20
  80. package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
  81. package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
  82. package/dist/Misc/graphql_server_codegen.js +12 -5
  83. package/dist/Misc/graphql_server_codegen.js.map +1 -1
  84. package/dist/Misc/remote_operations_codegen.d.ts.map +1 -1
  85. package/dist/Misc/remote_operations_codegen.js +4 -3
  86. package/dist/Misc/remote_operations_codegen.js.map +1 -1
  87. package/dist/Misc/schema-emit.d.ts.map +1 -1
  88. package/dist/Misc/schema-emit.js +2 -1
  89. package/dist/Misc/schema-emit.js.map +1 -1
  90. package/dist/Misc/sql_logging.d.ts +63 -8
  91. package/dist/Misc/sql_logging.d.ts.map +1 -1
  92. package/dist/Misc/sql_logging.js +169 -42
  93. package/dist/Misc/sql_logging.js.map +1 -1
  94. package/dist/Misc/util.d.ts +12 -0
  95. package/dist/Misc/util.d.ts.map +1 -1
  96. package/dist/Misc/util.js +71 -8
  97. package/dist/Misc/util.js.map +1 -1
  98. package/dist/index.d.ts +2 -0
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +2 -0
  101. package/dist/index.js.map +1 -1
  102. package/dist/plugins/index.d.ts +3 -0
  103. package/dist/plugins/index.d.ts.map +1 -1
  104. package/dist/plugins/index.js +28 -1
  105. package/dist/plugins/index.js.map +1 -1
  106. package/dist/runCodeGen.d.ts.map +1 -1
  107. package/dist/runCodeGen.js +40 -13
  108. package/dist/runCodeGen.js.map +1 -1
  109. package/package.json +29 -29
@@ -11,7 +11,7 @@ import { QueryParameterProcessor } from '@memberjunction/query-processor';
11
11
  // and the SS code path silently fails.
12
12
  import './providers/sqlserver/SQLServerCodeGenProvider.js';
13
13
  import { configInfo, currentWorkingDirectory, dbPlatform, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
14
- import { CodeNameFromString, EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, RunQuerySQLFilterManager, SeverityType } from "@memberjunction/core";
14
+ import { CodeNameFromString, EntityFieldInfo, EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, RunQuerySQLFilterManager, SeverityType } from "@memberjunction/core";
15
15
  import { MJEntityFieldSchema } from "@memberjunction/core-entities";
16
16
  import { logError, logMessage, logStatus, startSpinner, updateSpinner, succeedSpinner } from "../Misc/status_logging.js";
17
17
  import { SQLUtilityBase } from "./sql.js";
@@ -23,10 +23,15 @@ import { applySearchableFieldsCap, entityLevelEnableBlockedReason, isNarrativeFi
23
23
  import { mapExternalNativeTypeToMJ } from "../Misc/externalTypeMapping.js";
24
24
  import { SQLParser } from "@memberjunction/sql-parser";
25
25
  import { createDisplayName, generatePluralName, MJGlobal, ResolveSingleEntityResourceTarget, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
26
- import { v4 as uuidv4 } from 'uuid';
26
+ import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
27
27
  import * as fs from 'fs';
28
28
  import path from 'path';
29
+ import { canonicalJSONStringify, deepEqualJSON } from "../Misc/util.js";
29
30
  import { SQLLogging } from "../Misc/sql_logging.js";
31
+ import { computeFieldMetadataUpdate } from "./field-metadata-lock.js";
32
+ import { DecisionMetadataWriter } from "./decision-metadata-writer.js";
33
+ import { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
34
+ export { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
30
35
  export class ValidatorResult {
31
36
  constructor() {
32
37
  this.entityName = "";
@@ -161,47 +166,19 @@ export class ManageMetadataBase {
161
166
  return pool.queryWithParams(this.qsql(sql), params);
162
167
  }
163
168
  /**
164
- * Returns a sequence value safe to use for an EntityField INSERT under the given
165
- * EntityID, defaulting to `candidate` when it doesn't collide. Otherwise returns
166
- * `MAX(Sequence) + 1` for that entity.
169
+ * The `Sequence` expression for every EntityField INSERT CodeGen emits:
170
+ * `(SELECT COALESCE(MAX(Sequence), 0) + 1 FROM EntityField WHERE EntityID = '<id>')`.
167
171
  *
168
- * Why this exists: several insert paths (virtual-entity field sync, IS-A parent
169
- * field sync, schema-derived field creation) compute a deterministic sequence
170
- * up-front, but a partial prior run can leave rows at that exact ordinal under
171
- * the target EntityID. SS hides the collision because retried runs typically
172
- * succeed before failing; PG raises UQ_EntityField_EntityID_Sequence (~4 errors
173
- * per advanced-generation run). The values are temporary anyway —
174
- * spUpdateExistingEntityFieldsFromSchema renumbers them on the next pass.
175
- */
176
- async nextAvailableEntityFieldSequence(pool, entityId, candidate) {
177
- const schema = mj_core_schema();
178
- const tbl = this.qs(schema, 'EntityField');
179
- const seqCol = this.qi('Sequence');
180
- const entityIdCol = this.qi('EntityID');
181
- const sql = `SELECT
182
- ${this.coalesce(`MAX(${seqCol})`, '0')} AS ${this.qi('MaxSeq')},
183
- ${this.coalesce(`MAX(CASE WHEN ${seqCol} = @Candidate THEN 1 ELSE 0 END)`, '0')} AS ${this.qi('Hit')}
184
- FROM ${tbl}
185
- WHERE ${entityIdCol} = @EntityID`;
186
- try {
187
- const result = await this.runQueryWithParams(pool, sql, {
188
- EntityID: entityId,
189
- Candidate: candidate,
190
- });
191
- const row = result.recordset?.[0];
192
- if (!row)
193
- return candidate;
194
- const hit = Number(row.Hit ?? 0) > 0;
195
- if (!hit)
196
- return candidate;
197
- const maxSeq = Number(row.MaxSeq ?? 0);
198
- return maxSeq + 1;
199
- }
200
- catch {
201
- // If the lookup itself fails, fall back to the candidate. The downstream
202
- // INSERT will surface the real error if there's a true collision.
203
- return candidate;
204
- }
172
+ * Evaluated at APPLY time, never a literal. The value is disposable — the run's own
173
+ * spUpdateExistingEntityFieldsFromSchema pass and R__RefreshMetadata renumber every field from
174
+ * the schema — but it must be unique on ANY database in ANY order, because the INSERT is
175
+ * appended verbatim to a migration and Flyway runs every versioned migration before the
176
+ * repeatable renumber. Each INSERT re-evaluates MAX after the one before it, so a batch that
177
+ * executes sequentially in emission order rises in that order (#3670, #4202).
178
+ */
179
+ applyTimeEntityFieldSequenceSQL(entityID) {
180
+ // COALESCE is valid on both platforms and is the form the docs and the gate describe.
181
+ return `(SELECT COALESCE(MAX(${this.qi('Sequence')}), 0) + 1 FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ${this.qi('EntityID')} = '${entityID}')`;
205
182
  }
206
183
  /**
207
184
  * Wraps a SELECT query with a row limit.
@@ -276,6 +253,9 @@ export class ManageMetadataBase {
276
253
  static get newEntityList() {
277
254
  return this._newEntityList;
278
255
  }
256
+ static set newEntityList(value) {
257
+ this._newEntityList = value;
258
+ }
279
259
  static { this._modifiedEntityList = []; }
280
260
  /**
281
261
  * Globally scoped list of entities that have been modified during the metadata management process.
@@ -283,6 +263,108 @@ export class ManageMetadataBase {
283
263
  static get modifiedEntityList() {
284
264
  return this._modifiedEntityList;
285
265
  }
266
+ static set modifiedEntityList(value) {
267
+ this._modifiedEntityList = value;
268
+ }
269
+ static { this._newFieldSet = new Set(); }
270
+ static { this._changedFields = new Map(); }
271
+ static { this._changedFieldReport = []; }
272
+ static fieldKey(entityID, name) {
273
+ const eid = (entityID ?? '').trim().toLowerCase();
274
+ const fld = (name ?? '').trim().toLowerCase();
275
+ if (!eid || !fld) {
276
+ return null;
277
+ }
278
+ return `${eid}:${fld}`;
279
+ }
280
+ static registerNewField(entityID, name) {
281
+ const key = this.fieldKey(entityID, name);
282
+ if (key) {
283
+ this._newFieldSet.add(key);
284
+ }
285
+ }
286
+ static registerFieldChange(entityID, name, reasons) {
287
+ const key = this.fieldKey(entityID, name);
288
+ if (key) {
289
+ let set = this._changedFields.get(key);
290
+ if (!set) {
291
+ set = new Set();
292
+ this._changedFields.set(key, set);
293
+ }
294
+ for (const r of reasons) {
295
+ set.add(r);
296
+ }
297
+ }
298
+ }
299
+ static isFieldNew(entityID, name) {
300
+ const key = this.fieldKey(entityID, name);
301
+ return key ? this._newFieldSet.has(key) : false;
302
+ }
303
+ static fieldChangeReasons(entityID, name) {
304
+ const key = this.fieldKey(entityID, name);
305
+ const set = key ? this._changedFields.get(key) : undefined;
306
+ return set ?? new Set();
307
+ }
308
+ static isDisplayNameReopened(entityID, name) {
309
+ const reasons = this.fieldChangeReasons(entityID, name);
310
+ for (const r of reasons) {
311
+ if (DISPLAYNAME_REOPEN_REASONS.has(r)) {
312
+ return true;
313
+ }
314
+ }
315
+ return false;
316
+ }
317
+ static isTypeReopened(entityID, name) {
318
+ const reasons = this.fieldChangeReasons(entityID, name);
319
+ for (const r of reasons) {
320
+ if (TYPE_REOPEN_REASONS.has(r)) {
321
+ return true;
322
+ }
323
+ }
324
+ return false;
325
+ }
326
+ static get newFieldCount() {
327
+ return this._newFieldSet.size;
328
+ }
329
+ static get changedFieldCount() {
330
+ return this._changedFields.size;
331
+ }
332
+ static get changedFieldReport() {
333
+ return this._changedFieldReport;
334
+ }
335
+ static clearFieldTracking() {
336
+ this._newFieldSet.clear();
337
+ this._changedFields.clear();
338
+ this._changedFieldReport = [];
339
+ }
340
+ /**
341
+ * Takes a snapshot of EntityField rows from vwEntityFields for diffing.
342
+ * Only queries columns in TRACKED_FIELD_COLUMNS (Sequence is excluded).
343
+ */
344
+ async snapshotEntityFields(pool, entityIDs) {
345
+ const isScoped = entityIDs !== undefined && entityIDs.length > 0;
346
+ const filter = isScoped
347
+ ? `WHERE ${this.qi('EntityID')} IN (${entityIDs.map(id => `'${id}'`).join(',')})`
348
+ : '';
349
+ const cols = TRACKED_FIELD_COLUMNS.map(c => this.qi(c)).join(', ');
350
+ const sSQL = `SELECT ${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Entity')} AS ${this.qi('EntityName')}, ${this.qi('Name')}, ${cols}
351
+ FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
352
+ ${filter}`;
353
+ const result = await this.runQuery(pool, sSQL);
354
+ const map = new Map();
355
+ if (result && result.recordset) {
356
+ for (const r of result.recordset) {
357
+ map.set(String(r.ID).toLowerCase(), r);
358
+ }
359
+ }
360
+ return map;
361
+ }
362
+ findFieldByName(fields, name) {
363
+ if (!name)
364
+ return undefined;
365
+ const target = name.trim().toLowerCase();
366
+ return fields.find(f => (f.Name ?? '').trim().toLowerCase() === target);
367
+ }
286
368
  static { this._deletedEntitySchemaList = []; }
287
369
  /**
288
370
  * Schemas that lost an entity during this run.
@@ -939,29 +1021,63 @@ export class ManageMetadataBase {
939
1021
  logStatus(` > Entities config: "${ec.SchemaName}.${ec.BaseTable}" has no attributes to set — skipping`);
940
1022
  continue;
941
1023
  }
942
- // Look up the entity by BaseTable + SchemaName
1024
+ // Look up the entity by BaseTable + SchemaName, selecting current values for the named attributes
1025
+ const attrCols = attrs.map(([k]) => `${this.qi(k)}`).join(', ');
943
1026
  const entityResult = await this.runQueryWithParams(pool, `
944
- ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(schema, 'vwEntities')}
1027
+ ${this.selectTop(1, `ID, Name, ${attrCols}`, `FROM ${this.qs(schema, 'vwEntities')}
945
1028
  WHERE BaseTable = @BaseTable AND SchemaName = @SchemaName`)}
946
1029
  `, { 'BaseTable': ec.BaseTable, 'SchemaName': ec.SchemaName });
947
1030
  if (entityResult.recordset.length === 0) {
948
1031
  logError(` > Entities config: entity for "${ec.SchemaName}.${ec.BaseTable}" not found — skipping`);
949
1032
  continue;
950
1033
  }
951
- const entityId = entityResult.recordset[0].ID;
952
- const entityName = entityResult.recordset[0].Name;
953
- // Build a parameterized UPDATE with one SET clause per attribute
954
- const queryParams = { 'EntityID': entityId };
955
- const setClauses = [];
1034
+ const currentRow = entityResult.recordset[0];
1035
+ const entityId = String(currentRow.ID);
1036
+ const entityName = String(currentRow.Name);
1037
+ // Compare each attribute against the current database value; only include differing attributes
1038
+ const differingAttrs = [];
956
1039
  for (const [key, value] of attrs) {
957
- const paramName = `attr_${key}`;
958
- // Pass booleans through — both mssql and pg drivers handle native boolean values
959
- const sqlValue = value;
960
- queryParams[paramName] = sqlValue;
961
- setClauses.push(`${this.qi(key)} = @${paramName}`);
1040
+ const curVal = currentRow[key];
1041
+ let differs = false;
1042
+ let litVal = 'NULL';
1043
+ if (typeof value === 'boolean') {
1044
+ const curBool = curVal === true || curVal === 1 || curVal === '1';
1045
+ differs = curBool !== value;
1046
+ litVal = this.boolLit(value);
1047
+ }
1048
+ else if (typeof value === 'number') {
1049
+ const curNum = curVal === null || curVal === undefined ? null : Number(curVal);
1050
+ differs = curNum !== value;
1051
+ litVal = String(value);
1052
+ }
1053
+ else if (typeof value === 'string') {
1054
+ const curStr = curVal === null || curVal === undefined ? '' : String(curVal);
1055
+ differs = curStr !== value;
1056
+ litVal = `'${value.replace(/'/g, "''")}'`;
1057
+ }
1058
+ else if (value === null) {
1059
+ differs = curVal !== null && curVal !== undefined;
1060
+ litVal = 'NULL';
1061
+ }
1062
+ else {
1063
+ differs = JSON.stringify(curVal) !== JSON.stringify(value);
1064
+ litVal = `'${JSON.stringify(value).replace(/'/g, "''")}'`;
1065
+ }
1066
+ if (differs) {
1067
+ differingAttrs.push({ key, literalValue: litVal, summary: `${key}=${value}` });
1068
+ }
1069
+ }
1070
+ if (differingAttrs.length === 0) {
1071
+ continue;
962
1072
  }
963
- await this.runQueryWithParams(pool, `UPDATE ${this.qs(schema, 'Entity')} SET ${setClauses.join(', ')} WHERE ID = @EntityID`, queryParams);
964
- const attrSummary = attrs.map(([k, v]) => `${k}=${v}`).join(', ');
1073
+ // Build an UPDATE with literal values and execute through LogSQLAndExecute for migration capture
1074
+ const setClauses = differingAttrs.map(a => `${this.qi(a.key)} = ${a.literalValue}`);
1075
+ setClauses.unshift(`${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}`);
1076
+ const updateSQL = `UPDATE ${this.qs(schema, 'Entity')}
1077
+ SET ${setClauses.join(', ')}
1078
+ WHERE ${this.qi('ID')} = '${entityId}'`;
1079
+ await this.LogSQLAndExecute(pool, updateSQL, `Update attributes on "${entityName}" from additionalSchemaInfo`);
1080
+ const attrSummary = differingAttrs.map(a => a.summary).join(', ');
965
1081
  logStatus(` > Entities config: Set ${attrSummary} on "${entityName}"`);
966
1082
  updatedCount++;
967
1083
  }
@@ -1123,9 +1239,10 @@ export class ManageMetadataBase {
1123
1239
  const tableSQL = this.dbProvider.generateMaterializedTableSQL(matSchema, tableName, columns);
1124
1240
  const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(matSchema, viewName, tableName);
1125
1241
  // includeBatchSeparator: each is a single GO-free batch (executed via ds.query), but the
1126
- // migration file needs a GO between statements for Flyway/sqlcmd.
1127
- await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for base-view materialization of entity ${entity.Name}`, false, true);
1128
- await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for base-view materialization of entity ${entity.Name}`, false, true);
1242
+ // migration file needs a GO between statements for Flyway/sqlcmd. Pass the provider's
1243
+ // separator, not the 'GO' default: on PostgreSQL it is '' and a literal GO breaks replay.
1244
+ await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for base-view materialization of entity ${entity.Name}`, false, true, this.dbProvider.BatchSeparator);
1245
+ await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for base-view materialization of entity ${entity.Name}`, false, true, this.dbProvider.BatchSeparator);
1129
1246
  // 4) Upsert the MJ: Materialized Results row, keyed on (SourceType, SourceEntityID).
1130
1247
  const existing = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'MaterializedResult')} WHERE SourceType = @SourceType AND SourceEntityID = @EntityID`, { SourceType: 'EntityBaseView', EntityID: entity.ID });
1131
1248
  if (existing.recordset.length > 0) {
@@ -1383,8 +1500,8 @@ export class ManageMetadataBase {
1383
1500
  }
1384
1501
  const tableSQL = this.dbProvider.generateMaterializedTableSQL(coreSchema, tableName, analysis.columns);
1385
1502
  const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(coreSchema, viewName, tableName);
1386
- await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true);
1387
- await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true);
1503
+ await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true, this.dbProvider.BatchSeparator);
1504
+ await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true, this.dbProvider.BatchSeparator);
1388
1505
  // 3) Mint the read-only Virtual Entity over the wrapper view (idempotent by view).
1389
1506
  const existingVE = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'vwEntities')} WHERE BaseView = @V AND SchemaName = @S`, { V: viewName, S: coreSchema });
1390
1507
  let generatedEntityId = existingVE.recordset[0]?.ID;
@@ -1964,8 +2081,8 @@ export class ManageMetadataBase {
1964
2081
  * INTEGRITY CHECK — in a well-formed entity every base (non-virtual) field sequences BEFORE the
1965
2082
  * virtual/related fields, so the EntityField order matches the base view's `SELECT [base].*, <joins>`
1966
2083
  * column output. The positional save-capture in the data providers (e.g. SQLServerDataProvider's
1967
- * @ResultTable) relies on that alignment. CodeGen assigns newly-discovered columns a temporary
1968
- * `maxSequence + 100000` offset that updateExistingEntityFieldsFromSchema is supposed to renumber; if
2084
+ * @ResultTable) relies on that alignment. CodeGen inserts newly-discovered columns at an apply-time
2085
+ * `MAX(Sequence) + ordinal` placeholder that updateExistingEntityFieldsFromSchema is supposed to renumber; if
1969
2086
  * a base column is left sequenced AFTER a virtual field, the save-capture would mis-route values by
1970
2087
  * position. The providers now compensate (saves stay correct), but the metadata is still wrong — so we
1971
2088
  * scan for it after every metadata pass and log a prominent warning to drive the root cause out over time.
@@ -1978,7 +2095,7 @@ export class ManageMetadataBase {
1978
2095
  const sSQL = `SELECT ${this.qi('EntityID')}, ${this.qi('Entity')}, ${this.qi('Name')}, ${this.qi('Sequence')}, ${this.qi('IsVirtual')}
1979
2096
  FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
1980
2097
  ${excl}
1981
- ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}`;
2098
+ ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}, ${this.qi('Name')}`;
1982
2099
  const result = await this.runQuery(pool, sSQL);
1983
2100
  const rows = result.recordset;
1984
2101
  const isV = (v) => v === true || v === 1 || v === '1';
@@ -2011,7 +2128,7 @@ export class ManageMetadataBase {
2011
2128
  ` Healthy entities sequence ALL base (non-virtual) fields before virtual/related fields, so EntityField\n` +
2012
2129
  ` order matches the base view ([base].* then joins) that the data-provider save-capture relies on. When a\n` +
2013
2130
  ` base column sequences after a virtual field, the capture re-orders defensively (saves stay correct), but\n` +
2014
- ` the metadata is wrong. Usual cause: a newly-added column's temporary maxSequence+100000 offset was not\n` +
2131
+ ` the metadata is wrong. Usual cause: a newly-added column's apply-time MAX(Sequence)+ordinal placeholder was not\n` +
2015
2132
  ` renumbered. Fix the field's Sequence at the source so it sorts with the base columns.\n` +
2016
2133
  ` Affected (entity: base fields out of order):\n`;
2017
2134
  for (const o of offenders)
@@ -2464,11 +2581,8 @@ export class ManageMetadataBase {
2464
2581
  else {
2465
2582
  // 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
2466
2583
  newEntityFieldUUID = this.createNewUUID();
2467
- // Compute a non-colliding sequence by querying MAX(Sequence) for this entity. The
2468
- // ordinal `fieldSequence` (column index in the view) is correct for stable ordering
2469
- // when nothing exists yet, but a partial prior run could have left rows at the same
2470
- // ordinal under this EntityID — UQ_EntityField_EntityID_Sequence then fires on PG.
2471
- const safeSequence = await this.nextAvailableEntityFieldSequence(pool, entity.ID, fieldSequence);
2584
+ // Apply-time Sequence: see applyTimeEntityFieldSequenceSQL. A literal here would be
2585
+ // replayed verbatim from the CodeGen capture and collide on a fresh install.
2472
2586
  const q = (n) => this.qi(n);
2473
2587
  const sqlAdd = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
2474
2588
  ${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
@@ -2477,10 +2591,11 @@ export class ManageMetadataBase {
2477
2591
  ${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
2478
2592
  VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${String(veField.FieldName).replace(/'/g, "''")}', '${veField.Type}', ${this.boolLit(veField.AllowsNull)},
2479
2593
  ${veField.Length}, ${veField.Precision}, ${veField.Scale},
2480
- ${safeSequence}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
2594
+ ${this.applyTimeEntityFieldSequenceSQL(entity.ID)}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
2481
2595
  ${this.utcNow()}, ${this.utcNow()}
2482
2596
  )`;
2483
2597
  await this.LogSQLAndExecute(pool, sqlAdd, `SQL text to add virtual entity field ${veField.FieldName} for entity ${virtualEntity.Name}`);
2598
+ ManageMetadataBase.registerNewField(entity.ID, veField.FieldName);
2484
2599
  didUpdate = true;
2485
2600
  }
2486
2601
  }
@@ -2502,14 +2617,16 @@ export class ManageMetadataBase {
2502
2617
  if (virtualEntities.length === 0) {
2503
2618
  return;
2504
2619
  }
2505
- // Pre-build available entities list once (shared across all virtual entity decorations)
2620
+ // Pre-build available entities list once (shared across all virtual entity decorations).
2621
+ // Only single-column keys are offered as FK targets: an MJ foreign key references exactly one
2622
+ // column (RelatedEntityFieldName), so a composite-key entity cannot be the target of a soft FK.
2506
2623
  const availableEntities = md.Entities
2507
- .filter(e => !e.VirtualEntity && e.PrimaryKeys.length > 0)
2624
+ .filter(e => !e.VirtualEntity && e.PrimaryKeys.length === 1)
2508
2625
  .map(e => ({
2509
2626
  Name: e.Name,
2510
2627
  SchemaName: e.SchemaName,
2511
2628
  BaseTable: e.BaseTable,
2512
- PrimaryKeyField: e.PrimaryKeys[0]?.Name || 'ID'
2629
+ PrimaryKeyField: e.FirstPrimaryKey.Name // first-pk-ok: filtered to PrimaryKeys.length === 1 above; this is the FK target column offered to the LLM
2513
2630
  }));
2514
2631
  logStatus(` Decorating virtual entity fields with LLM (${virtualEntities.length} entities)...`);
2515
2632
  let decoratedCount = 0;
@@ -2641,7 +2758,7 @@ export class ManageMetadataBase {
2641
2758
  // Load VE EntityField rows from DB (we need the ID and auto-update flags)
2642
2759
  const schema = mj_core_schema();
2643
2760
  const fieldsSQL = `
2644
- SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, GeneratedFormSection, DisplayName, ExtendedType, CodeType
2761
+ SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, AutoUpdateExtendedType, GeneratedFormSection, DisplayName, ExtendedType, CodeType, ValueListType
2645
2762
  FROM ${this.qs(schema, 'EntityField')}
2646
2763
  WHERE EntityID = '${entity.ID}'
2647
2764
  `;
@@ -2662,16 +2779,18 @@ export class ManageMetadataBase {
2662
2779
  if (fieldCategories.length === 0)
2663
2780
  return false;
2664
2781
  const existingCategories = this.buildExistingCategorySet(dbFields);
2665
- await this.applyFieldCategories(pool, entity, dbFields, fieldCategories, existingCategories);
2782
+ const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
2783
+ await this.applyFieldCategories(pool, entity, dbFields, fieldCategories, existingCategories, { isNewEntity });
2666
2784
  // Apply entity icon if provided
2667
2785
  if (result.entityIcon) {
2668
- await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
2786
+ await this.applyEntityIcon(pool, entity.ID, result.entityIcon, entity.Name);
2669
2787
  }
2670
2788
  // Apply category info settings if provided
2671
2789
  if (result.categoryInfo && Object.keys(result.categoryInfo).length > 0) {
2672
- await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo);
2790
+ await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo, entity.Name);
2673
2791
  }
2674
2792
  logStatus(` Applied categories for VE ${entity.Name} (${fieldCategories.length} fields)`);
2793
+ await DecisionMetadataWriter.Instance.flushEntity(entity);
2675
2794
  return true;
2676
2795
  }
2677
2796
  /**
@@ -2769,9 +2888,12 @@ export class ManageMetadataBase {
2769
2888
  continue;
2770
2889
  }
2771
2890
  const escapedDescription = fd.description.replace(/'/g, "''");
2772
- let setClauses = `Description='${escapedDescription}'`;
2773
- // Apply extended type if provided and valid
2774
- if (fd.extendedType) {
2891
+ let setClauses = `Description='${escapedDescription}', ${this.qi('AutoUpdateDescription')} = ${this.boolLit(false)}`;
2892
+ // Apply extended type if provided, valid, and the field is not locked
2893
+ const isFieldNew = ManageMetadataBase.isFieldNew(entity.ID, field.Name);
2894
+ const isTypeReopened = ManageMetadataBase.isTypeReopened(entity.ID, field.Name);
2895
+ const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
2896
+ if (fd.extendedType && field.AutoUpdateExtendedType && (isNewEntity || isFieldNew || isTypeReopened)) {
2775
2897
  const validExtendedType = this.validateExtendedType(fd.extendedType);
2776
2898
  if (validExtendedType) {
2777
2899
  setClauses += `, ExtendedType='${validExtendedType}'`;
@@ -2788,12 +2910,9 @@ export class ManageMetadataBase {
2788
2910
  return true;
2789
2911
  }
2790
2912
  /**
2791
- * Valid values for EntityField.ExtendedType, plus common LLM aliases mapped to valid values.
2913
+ * Valid values for EntityField.ExtendedType. Domain lives on {@link EntityFieldInfo.ExtendedTypes}.
2792
2914
  */
2793
- static { this.VALID_EXTENDED_TYPES = new Set([
2794
- 'Code', 'Email', 'FaceTime', 'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
2795
- 'GeoCity', 'GeoPostalCode', 'GeoAddress', 'MSTeams', 'Other', 'SIP', 'SMS', 'Skype', 'Tel', 'URL', 'WhatsApp', 'ZoomMtg'
2796
- ]); }
2915
+ static { this.VALID_EXTENDED_TYPES = new Set(EntityFieldInfo.ExtendedTypes); }
2797
2916
  static { this.EXTENDED_TYPE_ALIASES = {
2798
2917
  'phone': 'Tel',
2799
2918
  'telephone': 'Tel',
@@ -2823,6 +2942,17 @@ export class ManageMetadataBase {
2823
2942
  'zoom': 'ZoomMtg',
2824
2943
  'whatsapp': 'WhatsApp',
2825
2944
  'skype': 'Skype',
2945
+ 'image': 'Image',
2946
+ 'photo': 'Image',
2947
+ 'picture': 'Image',
2948
+ 'logo': 'Image',
2949
+ 'avatar': 'Image',
2950
+ 'thumbnail': 'Image',
2951
+ 'color': 'Color',
2952
+ 'colour': 'Color',
2953
+ 'hex': 'Color',
2954
+ 'json': 'JSON',
2955
+ 'jsonb': 'JSON',
2826
2956
  }; }
2827
2957
  /**
2828
2958
  * Validates an LLM-suggested ExtendedType against the allowed values in EntityField.
@@ -2930,12 +3060,8 @@ export class ManageMetadataBase {
2930
3060
  else {
2931
3061
  // Create new virtual field record for this parent field
2932
3062
  const newFieldID = this.createNewUUID();
2933
- // Use high sequence — will be reordered by updateExistingEntityFieldsFromSchema.
2934
- // Query MAX(Sequence) at insert time so we never collide with an existing row
2935
- // (e.g. partial prior run left a record at the candidate ordinal under this
2936
- // EntityID — UQ_EntityField_EntityID_Sequence then fires on PG).
2937
- const candidate = 100000 + parentFields.indexOf(parentField);
2938
- const sequence = await this.nextAvailableEntityFieldSequence(pool, childEntity.ID, candidate);
3063
+ // Apply-time Sequence (see applyTimeEntityFieldSequenceSQL); reordered by
3064
+ // updateExistingEntityFieldsFromSchema afterwards.
2939
3065
  const q = (n) => this.qi(n);
2940
3066
  const sqlInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
2941
3067
  ${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
@@ -2947,9 +3073,10 @@ export class ManageMetadataBase {
2947
3073
  '${newFieldID}', '${childEntity.ID}', '${parentField.Name}',
2948
3074
  '${parentField.Type}', ${this.boolLit(parentField.AllowsNull)},
2949
3075
  ${parentField.Length}, ${parentField.Precision}, ${parentField.Scale},
2950
- ${sequence}, ${this.boolLit(true)}, ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)},
3076
+ ${this.applyTimeEntityFieldSequenceSQL(childEntity.ID)}, ${this.boolLit(true)}, ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)},
2951
3077
  ${this.utcNow()}, ${this.utcNow()})`;
2952
3078
  await this.LogSQLAndExecute(pool, sqlInsert, `Create IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
3079
+ ManageMetadataBase.registerNewField(childEntity.ID, parentField.Name);
2953
3080
  bUpdated = true;
2954
3081
  }
2955
3082
  }
@@ -3022,7 +3149,7 @@ export class ManageMetadataBase {
3022
3149
  RelatedEntityID IS NOT NULL AND
3023
3150
  IsVirtual = ${this.boolLit(false)} AND
3024
3151
  EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
3025
- ORDER BY RelatedEntityID`;
3152
+ ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
3026
3153
  const entityFieldsResult = await this.runQuery(pool, sSQL);
3027
3154
  const entityFields = entityFieldsResult.recordset;
3028
3155
  // Get the relationship counts for each entity
@@ -3200,7 +3327,7 @@ export class ManageMetadataBase {
3200
3327
  RelatedEntityID IS NOT NULL AND
3201
3328
  IsVirtual = ${this.boolLit(false)} AND
3202
3329
  EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
3203
- ORDER BY RelatedEntityID`;
3330
+ ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
3204
3331
  const entityFieldsResult = await this.runQuery(pool, sSQL);
3205
3332
  const entityFields = entityFieldsResult.recordset;
3206
3333
  const sSQLRelationship = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityRelationship')}`;
@@ -3430,7 +3557,7 @@ export class ManageMetadataBase {
3430
3557
  // AN: 14-June-2025 - See note below about the new order of these steps, this must
3431
3558
  // happen before we update existing entity fields from schema.
3432
3559
  const step2StartTime = new Date();
3433
- if (!await this.createNewEntityFieldsFromSchema(pool, scopedEntityIDs)) { // has its own internal filtering for exclude schema/table so don't pass in
3560
+ if (!await this.createNewEntityFieldsFromSchema(pool, scopedEntityIDs, excludeSchemas)) {
3434
3561
  logError('Error creating new entity fields from schema');
3435
3562
  bSuccess = false;
3436
3563
  }
@@ -3447,7 +3574,7 @@ export class ManageMetadataBase {
3447
3574
  }
3448
3575
  // AN: 14-June-2025 - we are now running this AFTER we create new entity fields from schema
3449
3576
  // which results in the same pattern of behavior as migrations where we first create new fields
3450
- // with VERY HIGH sequence numbers (e.g. 100,000 above what they will be approx) and then
3577
+ // at an apply-time MAX(Sequence)+ordinal placeholder (above every existing row) and then
3451
3578
  // we align them properly in sequential order from 1+ via this method below.
3452
3579
  const step3StartTime = new Date();
3453
3580
  if (!await this.updateExistingEntityFieldsFromSchema(pool, excludeSchemas, scopedEntityIDs)) {
@@ -3586,6 +3713,9 @@ export class ManageMetadataBase {
3586
3713
  try {
3587
3714
  logStatus(` Found ${configInfo.additionalSchemaInfo}, applying soft PK/FK configuration...`);
3588
3715
  const config = ManageMetadataBase.getSoftPKFKConfig();
3716
+ if (!config) {
3717
+ return true;
3718
+ }
3589
3719
  let totalPKs = 0;
3590
3720
  let totalFKs = 0;
3591
3721
  const schema = mj_core_schema();
@@ -3610,6 +3740,18 @@ export class ManageMetadataBase {
3610
3740
  const primaryKeys = table.PrimaryKey || [];
3611
3741
  if (primaryKeys.length > 0) {
3612
3742
  for (const pk of primaryKeys) {
3743
+ const checkSQL = `SELECT ${this.qi('IsPrimaryKey')}, ${this.qi('IsSoftPrimaryKey')}
3744
+ FROM ${this.qs(schema, 'EntityField')}
3745
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${pk.FieldName}'`;
3746
+ const checkRes = await this.runQuery(pool, checkSQL);
3747
+ if (checkRes.recordset.length > 0) {
3748
+ const row = checkRes.recordset[0];
3749
+ const isPk = row.IsPrimaryKey === true || row.IsPrimaryKey === 1 || row.IsPrimaryKey === '1';
3750
+ const isSoftPk = row.IsSoftPrimaryKey === true || row.IsSoftPrimaryKey === 1 || row.IsSoftPrimaryKey === '1';
3751
+ if (isPk && isSoftPk) {
3752
+ continue; // Already correctly set, skip write
3753
+ }
3754
+ }
3613
3755
  const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
3614
3756
  SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
3615
3757
  ${this.qi('IsPrimaryKey')} = ${this.boolLit(true)},
@@ -3635,11 +3777,29 @@ export class ManageMetadataBase {
3635
3777
  continue;
3636
3778
  }
3637
3779
  const relatedEntityId = relatedEntityResult.recordset[0].ID;
3780
+ const checkSQL = `SELECT ${this.qi('RelatedEntityID')}, ${this.qi('RelatedEntityFieldName')}, ${this.qi('IsSoftForeignKey')}, ${this.qi('AutoUpdateRelatedEntityInfo')}
3781
+ FROM ${this.qs(schema, 'EntityField')}
3782
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
3783
+ const checkRes = await this.runQuery(pool, checkSQL);
3784
+ if (checkRes.recordset.length > 0) {
3785
+ const row = checkRes.recordset[0];
3786
+ const curRelId = String(row.RelatedEntityID ?? '').trim().toLowerCase();
3787
+ const curRelField = String(row.RelatedEntityFieldName ?? '').trim().toLowerCase();
3788
+ const curSoftFk = row.IsSoftForeignKey === true || row.IsSoftForeignKey === 1 || row.IsSoftForeignKey === '1';
3789
+ const curAutoUpdate = row.AutoUpdateRelatedEntityInfo === true || row.AutoUpdateRelatedEntityInfo === 1 || row.AutoUpdateRelatedEntityInfo === '1';
3790
+ if (curRelId === String(relatedEntityId).trim().toLowerCase() &&
3791
+ curRelField === String(fk.RelatedField).trim().toLowerCase() &&
3792
+ curSoftFk &&
3793
+ !curAutoUpdate) {
3794
+ continue; // Already correctly set, skip write
3795
+ }
3796
+ }
3638
3797
  const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
3639
3798
  SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
3640
3799
  ${this.qi('RelatedEntityID')} = '${relatedEntityId}',
3641
3800
  ${this.qi('RelatedEntityFieldName')} = '${fk.RelatedField}',
3642
- ${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)}
3801
+ ${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)},
3802
+ ${this.qi('AutoUpdateRelatedEntityInfo')} = ${this.boolLit(false)}
3643
3803
  WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
3644
3804
  const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft FK for ${tableSchema}.${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
3645
3805
  if (result !== null) {
@@ -3672,7 +3832,7 @@ export class ManageMetadataBase {
3672
3832
  return true;
3673
3833
  try {
3674
3834
  // Load all existing EntityFieldValue rows for dedup checking
3675
- const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence`;
3835
+ const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence, Value, ID`;
3676
3836
  const allEFVResult = await this.runQuery(pool, efvSQL);
3677
3837
  const allEntityFieldValues = allEFVResult.recordset;
3678
3838
  let totalApplied = 0;
@@ -3899,7 +4059,8 @@ export class ManageMetadataBase {
3899
4059
  async dropExistingDefaultConstraint(pool, entity, fieldName) {
3900
4060
  try {
3901
4061
  const sqlDropDefaultConstraint = this.dbProvider.dropDefaultConstraintSQL(entity.SchemaName, entity.BaseTable, fieldName);
3902
- await this.LogSQLAndExecute(pool, sqlDropDefaultConstraint, `SQL text to drop default existing default constraints in entity ${entity.SchemaName}.${entity.BaseTable}`);
4062
+ // DECLARE-bearing block: close its batch in the replayable log (see SQLLogging.appendToSQLLogFile).
4063
+ await this.LogSQLAndExecute(pool, sqlDropDefaultConstraint, `SQL text to drop default existing default constraints in entity ${entity.SchemaName}.${entity.BaseTable}`, false, true, this.dbProvider.BatchSeparator);
3903
4064
  }
3904
4065
  catch (e) {
3905
4066
  logError(e);
@@ -3931,7 +4092,7 @@ export class ManageMetadataBase {
3931
4092
  // Use new API to generate entity description
3932
4093
  const result = await ag.generateEntityDescription(e, data[0].BaseTable, fields.map((f) => ({ Name: f.Name, Type: f.Type, IsNullable: f.AllowsNull, Description: f.Description })), currentUser);
3933
4094
  if (result?.entityDescription && result.entityDescription.length > 0) {
3934
- const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET Description = '${esc(result.entityDescription)}' WHERE Name = '${esc(e)}'`;
4095
+ const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET ${this.qi('Description')} = '${esc(result.entityDescription)}', ${this.qi('AutoUpdateDescription')} = ${this.boolLit(false)} WHERE ${this.qi('Name')} = '${esc(e)}'`;
3935
4096
  await this.LogSQLAndExecute(pool, sSQL, `SQL text to update entity description for entity ${e}`);
3936
4097
  }
3937
4098
  else {
@@ -4015,9 +4176,9 @@ export class ManageMetadataBase {
4015
4176
  *
4016
4177
  * @returns {string} - The SQL statement to retrieve pending entity fields.
4017
4178
  */
4018
- getPendingEntityFieldsSELECTSQL(entityIDs) {
4179
+ getPendingEntityFieldsSELECTSQL(entityIDs, excludeSchemas) {
4019
4180
  const schema = mj_core_schema();
4020
- return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs);
4181
+ return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs, excludeSchemas);
4021
4182
  }
4022
4183
  /**
4023
4184
  * This method builds a SQL Statement that will insert a row into the EntityField table with information about a new field.
@@ -4030,7 +4191,11 @@ export class ManageMetadataBase {
4030
4191
  const isPrimaryKey = n.FieldName?.trim().toLowerCase() === 'id';
4031
4192
  const isForeignKey = n.RelatedEntityID && n.RelatedEntityID.length > 0; // Foreign keys have RelatedEntityID set
4032
4193
  const isNameField = n.FieldName?.trim().toLowerCase() === 'name' || n.IsNameField;
4033
- const isEarlySequence = n.Sequence <= configInfo.newEntityDefaults?.IncludeFirstNFieldsAsDefaultInView;
4194
+ // The field's position in the schema (SourceOrdinal / column_id). `n.Sequence` is NOT that:
4195
+ // the pending-fields SELECT computes it as MAX+100000+ordinal, so comparing it against
4196
+ // IncludeFirstNFieldsAsDefaultInView could never be true and the setting was dead.
4197
+ const sourceOrdinal = typeof n.SourceOrdinal === 'number' && n.SourceOrdinal > 0 ? n.SourceOrdinal : 1;
4198
+ const isEarlySequence = sourceOrdinal <= configInfo.newEntityDefaults?.IncludeFirstNFieldsAsDefaultInView;
4034
4199
  const bDefaultInView = (isNameField || isEarlySequence) && !isPrimaryKey && !isForeignKey;
4035
4200
  const escapedDescription = n.Description ? `'${n.Description.replace(/'/g, "''")}'` : 'NULL';
4036
4201
  let fieldDisplayName = '';
@@ -4057,17 +4222,10 @@ export class ManageMetadataBase {
4057
4222
  : (parsedDefaultValue.trim().toLowerCase() === 'null' ? 'NULL' : `'${escapedParsedDefault}'`);
4058
4223
  const conflictCheck = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ID = '${newEntityFieldUUID}' OR (EntityID = '${n.EntityID}' AND Name = '${n.FieldName}')`;
4059
4224
  const guard = this.dbProvider.wrapInsertWithConflictGuard(conflictCheck);
4060
- // Sequence is the catalog ordinal of this column on the entity's BaseView
4061
- // (`SourceOrdinal` / column_id). Existing rows on the same entity are parked
4062
- // at Sequence+100000 first (see parkEntityFieldSequencesSQL) so this INSERT
4063
- // cannot collide on UQ_EntityField_EntityID_Sequence. Immediately after the
4064
- // batch, manageEntityFields calls spUpdateExistingEntityFieldsFromSchema
4065
- // which rewrites EVERY field on the entity from the live view — including
4066
- // parked rows. That proc must run AFTER views are current (CodeGen Pass 2,
4067
- // after SQL generation). Pass 1 still emits this SQL against whatever the
4068
- // view is at that moment; Pass 2 is the one that matches the finished BaseView.
4069
- const sourceOrdinal = typeof n.SourceOrdinal === 'number' && n.SourceOrdinal > 0 ? n.SourceOrdinal : 1;
4070
- const sequenceExpr = String(sourceOrdinal);
4225
+ // Sequence is an apply-time expression, never a literal: see applyTimeEntityFieldSequenceSQL.
4226
+ // `n.Sequence` (MAX+100000+ordinal from the pending SELECT) is only that query's ORDER BY key
4227
+ // and must never be written; `sourceOrdinal` above feeds DefaultInView only.
4228
+ const sequenceExpr = this.applyTimeEntityFieldSequenceSQL(n.EntityID);
4071
4229
  return `
4072
4230
  ${guard.prefix}
4073
4231
  INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')}
@@ -4121,7 +4279,7 @@ export class ManageMetadataBase {
4121
4279
  ${n.RelatedEntityID && n.RelatedEntityID.length > 0 ? `'${n.RelatedEntityID}'` : 'NULL'},
4122
4280
  ${n.RelatedEntityFieldName && n.RelatedEntityFieldName.length > 0 ? `'${n.RelatedEntityFieldName}'` : 'NULL'},
4123
4281
  ${this.boolLit(n.IsNameField !== null ? n.IsNameField : false)},
4124
- ${this.boolLit(n.FieldName === 'ID' || n.IsNameField)},
4282
+ ${this.boolLit(Boolean(n.IsNameField))},
4125
4283
  ${this.boolLit(n.RelatedEntityID && n.RelatedEntityID.length > 0)},
4126
4284
  ${this.boolLit(bDefaultInView)},
4127
4285
  ${this.boolLit(n.IsPrimaryKey)},
@@ -4141,45 +4299,20 @@ export class ManageMetadataBase {
4141
4299
  * @param sqlDefaultValue
4142
4300
  * @returns
4143
4301
  */
4144
- /**
4145
- * Park existing EntityField.Sequence values out of the 1..N catalog range so a
4146
- * following INSERT can use the real BaseView column_id without colliding on
4147
- * UQ_EntityField_EntityID_Sequence. The +100000 band is unique-safe; the
4148
- * subsequent spUpdateExistingEntityFieldsFromSchema rewrite brings every row
4149
- * (parked and new) back to live catalog order.
4150
- *
4151
- * Parks ONLY when nothing on the entity is parked yet, which is what makes a second
4152
- * emission for the same entity in the same run a no-op. `Sequence < 100000` alone does
4153
- * not achieve that: after the first park the rows below the band are precisely the ones
4154
- * the first pass just INSERTED at their catalog ordinals, so a second park lifts THOSE
4155
- * into the band — onto the row the first park moved from the same ordinal, and the
4156
- * migration dies on UQ_EntityField_EntityID_Sequence with a duplicate at 100000+ordinal.
4157
- * Reachable whenever an entity gains fields in both CodeGen passes: pass 1 for the real
4158
- * columns, pass 2 for the denormalized name column a new foreign key introduces.
4159
- */
4160
- parkEntityFieldSequencesSQL(entityID) {
4161
- const table = this.qs(mj_core_schema(), 'EntityField');
4162
- return `UPDATE ${table}
4163
- SET ${this.qi('Sequence')} = ${this.qi('Sequence')} + 100000
4164
- WHERE ${this.qi('EntityID')} = '${entityID}'
4165
- AND ${this.qi('Sequence')} < 100000
4166
- AND NOT EXISTS (
4167
- SELECT 1 FROM ${table}
4168
- WHERE ${this.qi('EntityID')} = '${entityID}'
4169
- AND ${this.qi('Sequence')} >= 100000
4170
- );`;
4171
- }
4172
4302
  parseDefaultValue(sqlDefaultValue) {
4173
4303
  if (sqlDefaultValue === null || sqlDefaultValue === undefined) {
4174
4304
  return null;
4175
4305
  }
4176
4306
  return this.dbProvider.parseColumnDefaultValue(sqlDefaultValue) ?? null;
4177
4307
  }
4178
- async createNewEntityFieldsFromSchema(pool, entityIDs) {
4308
+ async createNewEntityFieldsFromSchema(pool, entityIDs, excludeSchemas) {
4179
4309
  try {
4180
4310
  // entityIDs flows down to the provider's WHERE clause so the inline SELECT
4181
- // narrows to changed entities only when scoped.
4182
- const sSQL = this.getPendingEntityFieldsSELECTSQL(entityIDs);
4311
+ // narrows to changed entities only when scoped. excludeSchemas is the compiled
4312
+ // includeSchemas scope (out-of-allow-list schemas). Without it, Pass 1 inserts
4313
+ // pending fields for every schema in the database (Forms CodeGen emitted Common
4314
+ // Activity Files EntityField rows).
4315
+ const sSQL = this.getPendingEntityFieldsSELECTSQL(entityIDs, excludeSchemas);
4183
4316
  const newEntityFieldsResult = await this.runQuery(pool, sSQL);
4184
4317
  const newEntityFields = newEntityFieldsResult.recordset;
4185
4318
  if (newEntityFields.length > 0) {
@@ -4197,18 +4330,14 @@ export class ManageMetadataBase {
4197
4330
  // Batch size is configurable via `metadataInsertBatchSize` (default 250).
4198
4331
  const CHUNK_SIZE = configInfo.metadataInsertBatchSize ?? 250;
4199
4332
  const inserts = [];
4200
- const parkedEntityIDs = new Set();
4201
4333
  for (let i = 0; i < newEntityFields.length; ++i) {
4202
4334
  const n = newEntityFields[i];
4203
4335
  if (n.EntityID !== null && n.EntityID !== undefined && n.EntityID.length > 0) {
4204
4336
  // need to check for null entity id = that is because the above query can return candidate Entity Fields but the entities may not have been created if the entities
4205
4337
  // that would have been created violate rules - such as not having an ID column, etc.
4206
- if (!parkedEntityIDs.has(n.EntityID)) {
4207
- inserts.push(this.parkEntityFieldSequencesSQL(n.EntityID));
4208
- parkedEntityIDs.add(n.EntityID);
4209
- }
4210
4338
  const newEntityFieldUUID = this.createNewUUID();
4211
4339
  inserts.push(this.getPendingEntityFieldINSERTSQL(newEntityFieldUUID, n));
4340
+ ManageMetadataBase.registerNewField(n.EntityID, n.FieldName);
4212
4341
  }
4213
4342
  }
4214
4343
  for (let i = 0; i < inserts.length; i += CHUNK_SIZE) {
@@ -4304,11 +4433,20 @@ export class ManageMetadataBase {
4304
4433
  const label = isScoped
4305
4434
  ? `SQL text to update existing entity fields from schema (${entityIDs.length} scoped entities)`
4306
4435
  : `SQL text to update existing entity fields from schema`;
4307
- const result = await this.LogSQLAndExecute(pool, sSQL, label, true);
4308
- // result contains the updated entity fields. Get a distinct list of entity names
4309
- // and add them to the modified entity list if they're not already in there.
4310
- if (result && result.length > 0) {
4311
- ManageMetadataBase.addNewEntitiesToModifiedList(result.map((r) => r.EntityName));
4436
+ const before = await this.snapshotEntityFields(pool, entityIDs);
4437
+ await this.LogSQLAndExecute(pool, sSQL, label, true);
4438
+ const after = await this.snapshotEntityFields(pool, entityIDs);
4439
+ const changes = diffEntityFieldSnapshots(before, after, (e, n) => ManageMetadataBase.isFieldNew(e, n));
4440
+ if (changes.length > 0) {
4441
+ ManageMetadataBase.addNewEntitiesToModifiedList(changes.map(c => c.entityName));
4442
+ for (const c of changes) {
4443
+ ManageMetadataBase.registerFieldChange(c.entityID, c.fieldName, c.reasons);
4444
+ ManageMetadataBase._changedFieldReport.push({
4445
+ entityName: c.entityName,
4446
+ fieldName: c.fieldName,
4447
+ reasons: [...c.reasons],
4448
+ });
4449
+ }
4312
4450
  }
4313
4451
  return true;
4314
4452
  }
@@ -4461,13 +4599,22 @@ export class ManageMetadataBase {
4461
4599
  const sSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFieldsWithCheckConstraints')}${filter}`;
4462
4600
  const resultResult = await this.runQuery(pool, sSQL);
4463
4601
  const result = resultResult.recordset;
4464
- const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence`;
4602
+ const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence, Value, ID`;
4465
4603
  const allEntityFieldValuesResult = await this.runQuery(pool, efvSQL);
4466
4604
  const allEntityFieldValues = allEntityFieldValuesResult.recordset;
4467
- const efSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} ORDER BY EntityID, Sequence`;
4605
+ const efSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} ORDER BY EntityID, Sequence, Name, ID`;
4468
4606
  const allEntityFieldsResult = await this.runQuery(pool, efSQL);
4469
4607
  const allEntityFields = allEntityFieldsResult.recordset;
4470
4608
  const generationPromises = [];
4609
+ const ag = new AdvancedGeneration();
4610
+ // `skipDBUpdate` means load-only: `runValidationGeneration` is called below with
4611
+ // `generateNewCode = false`, and `generateValidatorFunctionFromCheckConstraint` only reaches an
4612
+ // LLM when that flag is true. Reading a validator back out of an Approved `GeneratedCode` record
4613
+ // is therefore a plain database read — gating it on the AI feature flag is what made
4614
+ // `mj codegen --no-ai` DELETE every committed `Validate()` override rather than preserve it, and
4615
+ // the `codegen-drift` gate (which runs `--no-ai`) then demanded that lossy output. Generation
4616
+ // stays gated; only the read is unconditional.
4617
+ const emitValidators = skipDBUpdate || ag.featureEnabled('ParseCheckConstraints');
4471
4618
  const columnLevelResults = result.filter((r) => r.EntityFieldID); // get the column level constraints
4472
4619
  const tableLevelResults = result.filter((r) => !r.EntityFieldID); // get the table level constraints
4473
4620
  for (const r of columnLevelResults) {
@@ -4501,9 +4648,8 @@ export class ManageMetadataBase {
4501
4648
  else {
4502
4649
  // if we get here that means we don't have a simple condition in the check constraint that the RegEx could parse. If Advanced Generation is enabled, we will
4503
4650
  // attempt to use an LLM to do things fancier now
4504
- if (configInfo.advancedGeneration?.enableAdvancedGeneration &&
4505
- configInfo.advancedGeneration?.features.find(f => f.name === 'ParseCheckConstraints' && f.enabled)) {
4506
- // the user has the feature turned on, let's generate a description of the constraint and then build a Validate function for the constraint
4651
+ if (emitValidators) {
4652
+ // either we are loading persisted validators, or the feature is on and we may generate new ones
4507
4653
  // run this in parallel
4508
4654
  generationPromises.push(this.runValidationGeneration(r, allEntityFields, !skipDBUpdate, currentUser));
4509
4655
  }
@@ -4512,9 +4658,8 @@ export class ManageMetadataBase {
4512
4658
  }
4513
4659
  // now for the table level constraints run the process for advanced generation
4514
4660
  for (const r of tableLevelResults) {
4515
- if (configInfo.advancedGeneration?.enableAdvancedGeneration &&
4516
- configInfo.advancedGeneration?.features.find(f => f.name === 'ParseCheckConstraints' && f.enabled)) {
4517
- // the user has the feature turned on, let's generate a description of the constraint and then build a Validate function for the constraint
4661
+ if (emitValidators) {
4662
+ // either we are loading persisted validators, or the feature is on and we may generate new ones
4518
4663
  // run this in parallel
4519
4664
  generationPromises.push(this.runValidationGeneration(r, allEntityFields, !skipDBUpdate, currentUser));
4520
4665
  }
@@ -4585,9 +4730,9 @@ export class ManageMetadataBase {
4585
4730
  }
4586
4731
  }
4587
4732
  try {
4588
- if (generateNewCode && configInfo.advancedGeneration?.enableAdvancedGeneration && configInfo.advancedGeneration?.features.find(f => f.name === 'ParseCheckConstraints' && f.enabled)) {
4733
+ const ag = new AdvancedGeneration();
4734
+ if (generateNewCode && ag.featureEnabled('ParseCheckConstraints')) {
4589
4735
  // feature is enabled, so let's call the AI to generate a function for us
4590
- const ag = new AdvancedGeneration();
4591
4736
  const entityFieldListInfo = allEntityFields.filter(item => item.Entity.trim().toLowerCase() === data.EntityName.trim().toLowerCase()).map(item => ` * ${item.Name} - ${item.Type}${item.AllowsNull ? ' (nullable)' : ' (not null)'}`).join('\n');
4592
4737
  // Use new API to parse check constraint
4593
4738
  const result = await ag.parseCheckConstraint(constraintDefinition, entityFieldListInfo, generatedValidationFunctionName, currentUser);
@@ -5775,7 +5920,8 @@ export class ManageMetadataBase {
5775
5920
  WHERE
5776
5921
  ${whereClause}
5777
5922
  ORDER BY
5778
- e.Name
5923
+ e.Name,
5924
+ e.ID
5779
5925
  `;
5780
5926
  const entitiesResult = await this.runQuery(pool, entitiesSQL);
5781
5927
  const entities = entitiesResult.recordset;
@@ -5801,11 +5947,16 @@ export class ManageMetadataBase {
5801
5947
  ef.AutoUpdateIncludeInUserSearchAPI,
5802
5948
  ef.AutoUpdateCategory,
5803
5949
  ef.AutoUpdateDisplayName,
5950
+ ef.AutoUpdateExtendedType,
5951
+ ef.ExtendedType,
5952
+ ef.CodeType,
5953
+ ef.GeneratedFormSection,
5804
5954
  ef.EntityIDFieldName,
5805
5955
  ef.RelatedEntity,
5806
5956
  ef.IsVirtual,
5807
5957
  ef.AllowUpdateAPI,
5808
5958
  ef.IsNameField,
5959
+ ef.ValueListType,
5809
5960
  ef.DefaultInView,
5810
5961
  ef.IncludeInUserSearchAPI,
5811
5962
  ef.UserSearchPredicateAPI,
@@ -5819,7 +5970,9 @@ export class ManageMetadataBase {
5819
5970
  ef.EntityID IN (${entityIds})
5820
5971
  ORDER BY
5821
5972
  ef.EntityID,
5822
- ef.Sequence
5973
+ ef.Sequence,
5974
+ ef.Name,
5975
+ ef.ID
5823
5976
  `;
5824
5977
  const fieldsResult = await this.runQuery(pool, fieldsSQL);
5825
5978
  const allFields = fieldsResult.recordset;
@@ -5834,6 +5987,9 @@ export class ManageMetadataBase {
5834
5987
  WHERE
5835
5988
  es.EntityID IN (${entityIds})
5836
5989
  AND es.Name = 'FieldCategoryInfo'
5990
+ ORDER BY
5991
+ es.EntityID,
5992
+ es.Name
5837
5993
  `;
5838
5994
  const settingsResult = await this.runQuery(pool, settingsSQL);
5839
5995
  const allSettings = settingsResult.recordset;
@@ -5931,16 +6087,26 @@ export class ManageMetadataBase {
5931
6087
  // Fields for this entity — O(1) lookup into the pre-grouped map (keyed on the normalized
5932
6088
  // EntityID) instead of a linear scan of the full pooled array.
5933
6089
  const fields = fieldsByEntity.get(String(entity.ID ?? '').trim().toLowerCase()) ?? [];
6090
+ // Decorate fields with change state from field tracking (C1/C3)
6091
+ for (const f of fields) {
6092
+ f.IsNew = ManageMetadataBase.isFieldNew(entity.ID, f.Name);
6093
+ f.DescriptionReopened = ManageMetadataBase.isDisplayNameReopened(entity.ID, f.Name);
6094
+ f.TypeReopened = ManageMetadataBase.isTypeReopened(entity.ID, f.Name);
6095
+ }
6096
+ const newFieldNames = new Set(fields.filter((f) => f.IsNew).map((f) => String(f.Name)));
6097
+ const typeReopenedNames = new Set(fields.filter((f) => f.TypeReopened).map((f) => String(f.Name)));
6098
+ const hasNewFields = newFieldNames.size > 0;
6099
+ const hasTypeReopened = typeReopenedNames.size > 0;
6100
+ const hasDescriptionReopened = fields.some((f) => f.DescriptionReopened);
5934
6101
  // Determine if this is a new entity (for DefaultForNewUser decision)
5935
6102
  const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
5936
- // Smart Field Identification
5937
- // Only run if at least one field allows auto-update for any of the smart field properties,
5938
- // or if entity-level search/FTS auto-update flags are set
5939
- const needsFieldAnalysis = fields.some((f) => f.AutoUpdateIsNameField || f.AutoUpdateDefaultInView || f.AutoUpdateIncludeInUserSearchAPI || f.AutoUpdateUserSearchPredicate || f.AutoUpdateFullTextSearch);
6103
+ // Smart Field Identification — FM4. Runs only when there is something new to decide about.
6104
+ const needsFieldAnalysis = (isNewEntity || hasNewFields || hasTypeReopened) && fields.some((f) => f.AutoUpdateIsNameField || f.AutoUpdateDefaultInView || f.AutoUpdateIncludeInUserSearchAPI || f.AutoUpdateUserSearchPredicate || f.AutoUpdateFullTextSearch);
5940
6105
  // entity-level auto-update flags come from the raw SQL query, not EntityInfo
5941
6106
  const entityRecord = entity;
5942
- const needsEntitySearchConfig = entityRecord.AutoUpdateAllowUserSearchAPI || entityRecord.AutoUpdateFullTextSearch;
6107
+ const needsEntitySearchConfig = isNewEntity && (Boolean(entityRecord.AutoUpdateAllowUserSearchAPI) || Boolean(entityRecord.AutoUpdateFullTextSearch));
5943
6108
  if (needsFieldAnalysis || needsEntitySearchConfig) {
6109
+ CodeGenReporter.Instance.counter('ai.smartFieldCalls');
5944
6110
  const fieldAnalysis = await ag.identifyFields({
5945
6111
  Name: entity.Name,
5946
6112
  Description: entity.Description,
@@ -5955,13 +6121,17 @@ export class ManageMetadataBase {
5955
6121
  AutoUpdateAllowUserSearchAPI: entityRecord.AutoUpdateAllowUserSearchAPI,
5956
6122
  FullTextSearchEnabled: entityRecord.FullTextSearchEnabled,
5957
6123
  AutoUpdateFullTextSearch: entityRecord.AutoUpdateFullTextSearch,
5958
- }, fields, fieldAnalysis);
6124
+ }, fields, fieldAnalysis, { isNewEntity, newFieldNames, typeReopenedNames });
5959
6125
  }
5960
6126
  }
5961
6127
  // Form Layout Generation
5962
- // Only run if at least one field allows auto-update
5963
- const needsCategoryGeneration = fields.some((f) => f.AutoUpdateCategory && (!f.Category || f.Category.trim() === ''));
6128
+ // Fires only on change events (new entity, new fields, reopened description/type) AND when there is work to do
6129
+ // (blank categories with AutoUpdateCategory=1, or reopened fields to review). Gated on events so omitted blank fields don't loop (§3.4).
6130
+ const hasCategoryNeeded = fields.some((f) => Boolean(f.AutoUpdateCategory) && (!f.Category || String(f.Category).trim() === ''));
6131
+ const needsCategoryGeneration = (isNewEntity || hasNewFields || hasDescriptionReopened || hasTypeReopened)
6132
+ && (hasCategoryNeeded || hasDescriptionReopened || hasTypeReopened);
5964
6133
  if (needsCategoryGeneration) {
6134
+ CodeGenReporter.Instance.counter('ai.formLayoutCalls');
5965
6135
  // Build IS-A parent chain context if this entity has a parent
5966
6136
  const parentChainContext = this.buildParentChainContext(entity, fields);
5967
6137
  const layoutAnalysis = await ag.generateFormLayout({
@@ -5977,6 +6147,8 @@ export class ManageMetadataBase {
5977
6147
  logStatus(` Applied form layout for ${entity.Name}`);
5978
6148
  }
5979
6149
  }
6150
+ // Flush decision metadata for this entity
6151
+ await DecisionMetadataWriter.Instance.flushEntity(entity);
5980
6152
  }
5981
6153
  catch (ex) {
5982
6154
  logError('Error Processing Entity Advanced Generation', ex);
@@ -6046,19 +6218,21 @@ export class ManageMetadataBase {
6046
6218
  /**
6047
6219
  * Apply smart field identification results to entity fields and entity-level search configuration
6048
6220
  */
6049
- async applySmartFieldIdentification(pool, entity, fields, result) {
6221
+ async applySmartFieldIdentification(pool, entity, fields, result, ctx) {
6050
6222
  // Run the guardrail pipeline once up front so every downstream applier
6051
6223
  // sees the same already-normalized result. See search-guardrails.ts for
6052
6224
  // the rules. Mutates result in place — see normalizeSearchFlagsInPlace.
6053
6225
  this.normalizeSearchFlagsInPlace(entity, fields, result);
6054
6226
  const sqlStatements = [];
6055
- this.applyNameFieldUpdates(sqlStatements, fields, result);
6056
- this.applyDefaultInViewUpdates(sqlStatements, fields, result);
6057
- this.applySearchableFieldUpdates(sqlStatements, fields, result, entity);
6058
- this.applySearchPredicateUpdates(sqlStatements, fields, result);
6059
- this.applyEntitySearchConfig(sqlStatements, entity, result);
6227
+ this.applyNameFieldUpdates(sqlStatements, fields, result, entity);
6228
+ this.applyDefaultInViewUpdates(sqlStatements, fields, result, entity, ctx);
6229
+ this.applySearchableFieldUpdates(sqlStatements, fields, result, entity, ctx);
6230
+ this.applySearchPredicateUpdates(sqlStatements, fields, result, entity, ctx);
6231
+ if (!ctx || ctx.isNewEntity) {
6232
+ this.applyEntitySearchConfig(sqlStatements, entity, result);
6233
+ }
6060
6234
  if (configInfo.advancedGeneration?.allowFullTextSearchAutoUpdate) {
6061
- this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result);
6235
+ this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result, ctx);
6062
6236
  }
6063
6237
  // Execute all updates in one batch
6064
6238
  if (sqlStatements.length > 0) {
@@ -6197,11 +6371,11 @@ export class ManageMetadataBase {
6197
6371
  * `AutoUpdateIsNameField=0` are never touched in either direction — the user owns
6198
6372
  * them, and `EntityInfo.NameField`'s literal-`Name` preference arbitrates at runtime.
6199
6373
  */
6200
- applyNameFieldUpdates(sqlStatements, fields, result) {
6374
+ applyNameFieldUpdates(sqlStatements, fields, result, entity) {
6201
6375
  const nameFieldNames = result.nameFields ?? [];
6202
6376
  // Surface LLM candidates that don't exist on the entity (same diagnostics as before).
6203
6377
  for (const nfName of nameFieldNames) {
6204
- if (!fields.some(f => f.Name === nfName)) {
6378
+ if (!this.findFieldByName(fields, nfName)) {
6205
6379
  logError(`Smart field identification returned invalid nameField: '${nfName}' not found in entity fields`);
6206
6380
  }
6207
6381
  }
@@ -6214,6 +6388,9 @@ export class ManageMetadataBase {
6214
6388
  WHERE ID = '${winner.ID}'
6215
6389
  AND AutoUpdateIsNameField = ${this.boolLit(true)}
6216
6390
  `);
6391
+ if (entity?.Name) {
6392
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(winner.Name), 'IsNameField', true);
6393
+ }
6217
6394
  }
6218
6395
  // Clear every other flagged field that allows auto-update — single winner, always.
6219
6396
  for (const f of fields) {
@@ -6226,6 +6403,9 @@ export class ManageMetadataBase {
6226
6403
  WHERE ID = '${f.ID}'
6227
6404
  AND AutoUpdateIsNameField = ${this.boolLit(true)}
6228
6405
  `);
6406
+ if (entity?.Name) {
6407
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(f.Name), 'IsNameField', false);
6408
+ }
6229
6409
  }
6230
6410
  }
6231
6411
  /**
@@ -6247,7 +6427,7 @@ export class ManageMetadataBase {
6247
6427
  return this.preferLiteralNameField(eligibleFlagged);
6248
6428
  }
6249
6429
  for (const candidateName of rankedCandidates) {
6250
- const candidate = fields.find(f => f.Name === candidateName);
6430
+ const candidate = this.findFieldByName(fields, candidateName);
6251
6431
  if (candidate && this.isFieldEligibleForNameField(candidate)) {
6252
6432
  return candidate;
6253
6433
  }
@@ -6355,12 +6535,15 @@ export class ManageMetadataBase {
6355
6535
  /**
6356
6536
  * Generate SQL UPDATEs for DefaultInView on the identified default view fields
6357
6537
  */
6358
- applyDefaultInViewUpdates(sqlStatements, fields, result) {
6538
+ applyDefaultInViewUpdates(sqlStatements, fields, result, entityOrCtx, maybeCtx) {
6539
+ const entity = (entityOrCtx && 'Name' in entityOrCtx) ? entityOrCtx : undefined;
6540
+ const ctx = (entityOrCtx && 'newFieldNames' in entityOrCtx) ? entityOrCtx : maybeCtx;
6541
+ const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
6359
6542
  // Guard against a malformed LLM result that omits the array entirely.
6360
6543
  // Mirrors the defensive handling in the sibling apply* methods.
6361
6544
  const defaultInView = result.defaultInView ?? [];
6362
- const defaultInViewFields = fields.filter(f => defaultInView.includes(f.Name) && f.AutoUpdateDefaultInView && f.ID);
6363
- const missingFields = defaultInView.filter(name => !fields.some(f => f.Name === name));
6545
+ const defaultInViewFields = fields.filter(f => defaultInView.some(n => n.toLowerCase() === String(f.Name ?? '').toLowerCase()) && f.AutoUpdateDefaultInView && f.ID && inScope(f));
6546
+ const missingFields = defaultInView.filter(name => !this.findFieldByName(fields, name));
6364
6547
  if (missingFields.length > 0) {
6365
6548
  logError(`Smart field identification returned invalid defaultInView fields: ${missingFields.join(', ')} not found in entity`);
6366
6549
  }
@@ -6372,6 +6555,9 @@ export class ManageMetadataBase {
6372
6555
  WHERE ID = '${field.ID}'
6373
6556
  AND AutoUpdateDefaultInView = ${this.boolLit(true)}
6374
6557
  `);
6558
+ if (entity?.Name) {
6559
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'DefaultInView', true);
6560
+ }
6375
6561
  }
6376
6562
  }
6377
6563
  }
@@ -6386,12 +6572,13 @@ export class ManageMetadataBase {
6386
6572
  * always-wrong cases — they cause the data provider to emit unindexed scans on
6387
6573
  * every search.
6388
6574
  */
6389
- applySearchableFieldUpdates(sqlStatements, fields, result, entity) {
6575
+ applySearchableFieldUpdates(sqlStatements, fields, result, entity, ctx) {
6576
+ const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
6390
6577
  if (!result.searchableFields || result.searchableFields.length === 0) {
6391
6578
  return;
6392
6579
  }
6393
- const searchableFields = fields.filter(f => result.searchableFields.includes(f.Name) && f.AutoUpdateIncludeInUserSearchAPI && f.ID);
6394
- const missingSearchableFields = result.searchableFields.filter(name => !fields.some(f => f.Name === name));
6580
+ const searchableFields = fields.filter(f => result.searchableFields.some(n => n.toLowerCase() === String(f.Name ?? '').toLowerCase()) && f.AutoUpdateIncludeInUserSearchAPI && f.ID && inScope(f));
6581
+ const missingSearchableFields = result.searchableFields.filter(name => !this.findFieldByName(fields, name));
6395
6582
  if (missingSearchableFields.length > 0) {
6396
6583
  logError(`Smart field identification returned invalid searchableFields: ${missingSearchableFields.join(', ')} not found in entity`);
6397
6584
  }
@@ -6409,6 +6596,9 @@ export class ManageMetadataBase {
6409
6596
  WHERE ID = '${field.ID}'
6410
6597
  AND AutoUpdateIncludeInUserSearchAPI = ${this.boolLit(true)}
6411
6598
  `);
6599
+ if (entity?.Name) {
6600
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'IncludeInUserSearchAPI', true);
6601
+ }
6412
6602
  }
6413
6603
  }
6414
6604
  }
@@ -6426,7 +6616,7 @@ export class ManageMetadataBase {
6426
6616
  if (!textTypes.has(type))
6427
6617
  return false;
6428
6618
  // Unbounded text on a non-FTX entity cannot be index-seeked.
6429
- const length = field.Length;
6619
+ const length = (field.Length ?? field.MaxLength);
6430
6620
  if (length === -1 && !ftxEnabled)
6431
6621
  return false;
6432
6622
  return true;
@@ -6434,17 +6624,20 @@ export class ManageMetadataBase {
6434
6624
  /**
6435
6625
  * Generate SQL UPDATEs for UserSearchPredicateAPI on each searchable field
6436
6626
  */
6437
- applySearchPredicateUpdates(sqlStatements, fields, result) {
6627
+ applySearchPredicateUpdates(sqlStatements, fields, result, entityOrCtx, maybeCtx) {
6628
+ const entity = (entityOrCtx && 'Name' in entityOrCtx) ? entityOrCtx : undefined;
6629
+ const ctx = (entityOrCtx && 'newFieldNames' in entityOrCtx) ? entityOrCtx : maybeCtx;
6630
+ const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
6438
6631
  if (!result.searchPredicates || result.searchPredicates.length === 0) {
6439
6632
  return;
6440
6633
  }
6441
6634
  for (const sp of result.searchPredicates) {
6442
- const field = fields.find(f => f.Name === sp.field);
6635
+ const field = this.findFieldByName(fields, sp.field);
6443
6636
  if (!field) {
6444
6637
  logError(`Smart field identification returned invalid searchPredicate field: '${sp.field}' not found in entity fields`);
6445
6638
  continue;
6446
6639
  }
6447
- if (!field.AutoUpdateUserSearchPredicate || !field.ID) {
6640
+ if (!field.AutoUpdateUserSearchPredicate || !field.ID || !inScope(field)) {
6448
6641
  continue;
6449
6642
  }
6450
6643
  // Only update if the current value differs from the recommended predicate
@@ -6455,6 +6648,9 @@ export class ManageMetadataBase {
6455
6648
  WHERE ID = '${field.ID}'
6456
6649
  AND AutoUpdateUserSearchPredicate = ${this.boolLit(true)}
6457
6650
  `);
6651
+ if (entity?.Name) {
6652
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'UserSearchPredicateAPI', sp.predicate);
6653
+ }
6458
6654
  }
6459
6655
  }
6460
6656
  }
@@ -6502,6 +6698,9 @@ export class ManageMetadataBase {
6502
6698
  WHERE ID = '${entity.ID}'
6503
6699
  AND AutoUpdateAllowUserSearchAPI = ${this.boolLit(true)}
6504
6700
  `);
6701
+ if (entity.Name) {
6702
+ DecisionMetadataWriter.Instance.recordEntityDecision(entity.Name, 'AllowUserSearchAPI', newValue);
6703
+ }
6505
6704
  }
6506
6705
  }
6507
6706
  /**
@@ -6532,9 +6731,10 @@ export class ManageMetadataBase {
6532
6731
  /**
6533
6732
  * Generate SQL UPDATEs for entity-level and field-level FullTextSearch configuration
6534
6733
  */
6535
- applyFullTextSearchUpdates(sqlStatements, entity, fields, result) {
6536
- // Entity-level FullTextSearchEnabled
6537
- if (result.enableFullTextSearch != null && entity.AutoUpdateFullTextSearch) {
6734
+ applyFullTextSearchUpdates(sqlStatements, entity, fields, result, ctx) {
6735
+ const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
6736
+ // Entity-level FullTextSearchEnabled (only for new entities)
6737
+ if ((!ctx || ctx.isNewEntity) && result.enableFullTextSearch != null && entity.AutoUpdateFullTextSearch) {
6538
6738
  const newValue = result.enableFullTextSearch;
6539
6739
  const currentValue = !!entity.FullTextSearchEnabled;
6540
6740
  if (newValue !== currentValue) {
@@ -6544,6 +6744,9 @@ export class ManageMetadataBase {
6544
6744
  WHERE ID = '${entity.ID}'
6545
6745
  AND AutoUpdateFullTextSearch = ${this.boolLit(true)}
6546
6746
  `);
6747
+ if (entity.Name) {
6748
+ DecisionMetadataWriter.Instance.recordEntityDecision(entity.Name, 'FullTextSearchEnabled', newValue);
6749
+ }
6547
6750
  }
6548
6751
  }
6549
6752
  // Field-level FullTextSearchEnabled
@@ -6551,12 +6754,12 @@ export class ManageMetadataBase {
6551
6754
  return;
6552
6755
  }
6553
6756
  for (const ftsFieldName of result.fullTextSearchFields) {
6554
- const field = fields.find(f => f.Name === ftsFieldName);
6757
+ const field = this.findFieldByName(fields, ftsFieldName);
6555
6758
  if (!field) {
6556
6759
  logError(`Smart field identification returned invalid fullTextSearchField: '${ftsFieldName}' not found in entity fields`);
6557
6760
  continue;
6558
6761
  }
6559
- if (!field.AutoUpdateFullTextSearch || !field.ID) {
6762
+ if (!field.AutoUpdateFullTextSearch || !field.ID || !inScope(field)) {
6560
6763
  continue;
6561
6764
  }
6562
6765
  if (!field.FullTextSearchEnabled) {
@@ -6566,6 +6769,9 @@ export class ManageMetadataBase {
6566
6769
  WHERE ID = '${field.ID}'
6567
6770
  AND AutoUpdateFullTextSearch = ${this.boolLit(true)}
6568
6771
  `);
6772
+ if (entity.Name) {
6773
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'FullTextSearchEnabled', true);
6774
+ }
6569
6775
  }
6570
6776
  }
6571
6777
  }
@@ -6583,29 +6789,29 @@ export class ManageMetadataBase {
6583
6789
  // Defensive: a malformed LLM result can leave fieldCategories undefined or
6584
6790
  // non-array; both consumers below iterate it, so coerce to a safe array.
6585
6791
  const fieldCategories = Array.isArray(result.fieldCategories) ? result.fieldCategories : [];
6586
- await this.applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories);
6792
+ await this.applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories, { isNewEntity });
6587
6793
  // Auto-detect geo-capable entities and set SupportsGeoCoding
6588
- await this.detectAndSetGeoCodingSupport(pool, entity, fieldCategories);
6794
+ await this.detectAndSetGeoCodingSupport(pool, entity);
6589
6795
  if (result.entityIcon) {
6590
- await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
6796
+ await this.applyEntityIcon(pool, entity.ID, result.entityIcon, entity.Name);
6591
6797
  }
6592
6798
  // Resolve categoryInfo from new or legacy format
6593
6799
  const categoryInfoToStore = result.categoryInfo ||
6594
6800
  (result.categoryIcons ?
6595
6801
  Object.fromEntries(Object.entries(result.categoryIcons).map(([cat, icon]) => [cat, { icon, description: '' }])) : null);
6596
6802
  if (categoryInfoToStore) {
6597
- await this.applyCategoryInfoSettings(pool, entity.ID, categoryInfoToStore);
6803
+ await this.applyCategoryInfoSettings(pool, entity.ID, categoryInfoToStore, entity.Name);
6598
6804
  }
6599
6805
  if (isNewEntity && result.entityImportance) {
6600
- await this.applyEntityImportance(pool, entity.ID, result.entityImportance);
6806
+ await this.applyEntityImportance(pool, entity.ID, result.entityImportance, entity.Name);
6601
6807
  }
6602
6808
  }
6603
6809
  /**
6604
- * Detect if an entity has geo-capable fields based on LLM-assigned ExtendedType values.
6810
+ * Detect if an entity has geo-capable fields based on persisted ExtendedType values in the database.
6605
6811
  * If any field has a Geo* ExtendedType, set Entity.SupportsGeoCoding = 1.
6606
6812
  * Respects the AutoUpdateSupportsGeoCoding flag — if 0, the value is locked.
6607
6813
  */
6608
- async detectAndSetGeoCodingSupport(pool, entity, fieldCategories) {
6814
+ async detectAndSetGeoCodingSupport(pool, entity) {
6609
6815
  const schema = mj_core_schema();
6610
6816
  // Check if the entity's AutoUpdateSupportsGeoCoding flag allows us to modify it
6611
6817
  const autoUpdateResult = await pool.query(`
@@ -6617,14 +6823,8 @@ export class ManageMetadataBase {
6617
6823
  if (!row || !row.AutoUpdateSupportsGeoCoding) {
6618
6824
  return; // Flag is locked, don't modify
6619
6825
  }
6620
- // Detect geo-capable fields from the LLM results
6621
- const geoExtendedTypes = new Set([
6622
- 'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
6623
- 'GeoCity', 'GeoPostalCode', 'GeoAddress'
6624
- ]);
6625
- const hasGeoFields = fieldCategories.some(fc => fc.extendedType != null && geoExtendedTypes.has(fc.extendedType));
6626
- // Also check existing fields in the database for Geo* ExtendedTypes
6627
- // (in case the LLM didn't re-assign them this run but they were previously set)
6826
+ // Check existing fields in the database for Geo* ExtendedTypes
6827
+ // (relies on persisted state after applyFieldCategories has run)
6628
6828
  const existingGeoResult = await pool.query(`
6629
6829
  SELECT COUNT(*) AS ${this.qi('GeoFieldCount')}
6630
6830
  FROM ${this.qs(schema, 'EntityField')}
@@ -6632,7 +6832,7 @@ export class ManageMetadataBase {
6632
6832
  AND ${this.qi('ExtendedType')} IN ('Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince', 'GeoCity', 'GeoPostalCode', 'GeoAddress')
6633
6833
  `);
6634
6834
  const existingGeoCount = existingGeoResult?.recordset?.[0]?.GeoFieldCount ?? 0;
6635
- const shouldSupportGeo = hasGeoFields || existingGeoCount > 0;
6835
+ const shouldSupportGeo = existingGeoCount > 0;
6636
6836
  const currentValue = row.SupportsGeoCoding ? true : false;
6637
6837
  if (shouldSupportGeo !== currentValue) {
6638
6838
  await this.LogSQLAndExecute(pool, `
@@ -6640,7 +6840,8 @@ export class ManageMetadataBase {
6640
6840
  SET ${this.qi('SupportsGeoCoding')} = ${this.boolLit(shouldSupportGeo)}
6641
6841
  WHERE ${this.qi('ID')} = '${entity.ID}' AND ${this.qi('AutoUpdateSupportsGeoCoding')} = ${this.boolLit(true)}
6642
6842
  `, `Set SupportsGeoCoding = ${shouldSupportGeo} for ${entity.Name}`);
6643
- logStatus(` Entity ${entity.Name}: SupportsGeoCoding = ${shouldSupportGeo ? 1 : 0} (auto-detected from ${hasGeoFields ? 'LLM' : 'existing'} geo fields)`);
6843
+ logStatus(` Entity ${entity.Name}: SupportsGeoCoding = ${shouldSupportGeo ? 1 : 0} (auto-detected from persisted geo fields)`);
6844
+ DecisionMetadataWriter.Instance.recordEntityDecision(entity.Name, 'SupportsGeoCoding', shouldSupportGeo);
6644
6845
  // Queue for late-phase view regeneration — the view was already generated
6645
6846
  // before this flag was set, so it needs to be regenerated with the geo JOIN
6646
6847
  ManageMetadataBase.AddEntityRequiringViewRegen(entity.Name, 'Geocoding');
@@ -6687,55 +6888,72 @@ export class ManageMetadataBase {
6687
6888
  * Enforces stability rules: fields with existing categories cannot move to NEW categories.
6688
6889
  * All SQL updates are batched into a single execution for performance.
6689
6890
  */
6690
- async applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories) {
6891
+ async applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories, ctx) {
6691
6892
  const sqlStatements = [];
6893
+ const reporter = CodeGenReporter.Instance;
6894
+ // Count fields with blank Category that the LLM omitted (FM2 re-trigger loop tracking)
6895
+ const proposedNames = new Set(fieldCategories.map(fc => (fc.fieldName ?? '').trim().toLowerCase()));
6896
+ let omittedBlankCount = 0;
6897
+ for (const field of fields) {
6898
+ const isBlankCategory = !field.Category || field.Category.trim() === '';
6899
+ if (isBlankCategory && field.AutoUpdateCategory && !proposedNames.has(field.Name.trim().toLowerCase())) {
6900
+ omittedBlankCount++;
6901
+ }
6902
+ }
6903
+ if (omittedBlankCount > 0) {
6904
+ reporter.counter('ai.fieldsOmittedByLLM', omittedBlankCount);
6905
+ }
6692
6906
  for (const fieldCategory of fieldCategories) {
6693
- const field = fields.find(f => f.Name === fieldCategory.fieldName);
6694
- if (field && field.AutoUpdateCategory && field.ID) {
6695
- // Override category to "System Metadata" for __mj_ fields (system audit fields)
6696
- let category = fieldCategory.category;
6697
- if (field.Name.startsWith('__mj_')) {
6698
- category = 'System Metadata';
6699
- }
6700
- // ENFORCEMENT: Prevent category renaming
6701
- const fieldHasExistingCategory = field.Category != null && field.Category.trim() !== '';
6702
- const categoryIsNew = !existingCategories.has(category);
6703
- if (fieldHasExistingCategory && categoryIsNew) {
6704
- logStatus(` Rejected category change for field '${field.Name}': cannot move from existing category '${field.Category}' to new category '${category}'. Keeping original category.`);
6705
- category = field.Category;
6706
- }
6707
- const setClauses = [];
6708
- if (field.Category !== category) {
6709
- setClauses.push(`Category = '${category.replace(/'/g, "''")}'`);
6710
- }
6711
- if (field.GeneratedFormSection !== 'Category') {
6712
- setClauses.push(`GeneratedFormSection = 'Category'`);
6713
- }
6714
- if (fieldCategory.displayName && field.AutoUpdateDisplayName && field.DisplayName !== fieldCategory.displayName) {
6715
- setClauses.push(`DisplayName = '${fieldCategory.displayName.replace(/'/g, "''")}'`);
6716
- }
6717
- if (fieldCategory.extendedType !== undefined && field.ExtendedType !== fieldCategory.extendedType) {
6718
- const extendedType = fieldCategory.extendedType === null ? 'NULL' : `'${String(fieldCategory.extendedType).replace(/'/g, "''")}'`;
6719
- setClauses.push(`ExtendedType = ${extendedType}`);
6907
+ const field = this.findFieldByName(fields, fieldCategory.fieldName);
6908
+ if (!field) {
6909
+ logError(`Form layout returned invalid fieldName: '${fieldCategory.fieldName}' not found in entity`);
6910
+ continue;
6911
+ }
6912
+ const lockCtx = {
6913
+ isNewEntity: ctx.isNewEntity,
6914
+ isNewField: ManageMetadataBase.isFieldNew(entity.ID, field.Name),
6915
+ descriptionReopened: ManageMetadataBase.isDisplayNameReopened(entity.ID, field.Name),
6916
+ typeReopened: ManageMetadataBase.isTypeReopened(entity.ID, field.Name),
6917
+ existingCategories,
6918
+ };
6919
+ const update = computeFieldMetadataUpdate(field, fieldCategory, lockCtx, this.validateExtendedType.bind(this), (val, fn) => this.sanitizeCodeType(val, fn ?? field.Name, entity.Name));
6920
+ for (const skip of update.skipped) {
6921
+ if (skip.reason === 'locked') {
6922
+ reporter.counter('ai.fieldsLocked');
6720
6923
  }
6721
- if (fieldCategory.codeType !== undefined) {
6722
- const sanitized = this.sanitizeCodeType(fieldCategory.codeType, field.Name, entity.Name);
6723
- if (field.CodeType !== sanitized) {
6724
- const codeType = sanitized == null ? 'NULL' : `'${sanitized.replace(/'/g, "''")}'`;
6725
- setClauses.push(`CodeType = ${codeType}`);
6726
- }
6924
+ else if (skip.reason === 'invalid') {
6925
+ reporter.counter('ai.fieldsInvalidProposal');
6727
6926
  }
6728
- if (setClauses.length > 0) {
6729
- // only generate an UPDATE if we have 1+ set clause
6730
- sqlStatements.push(`\n-- UPDATE Entity Field Category Info ${entity.Name}.${field.Name} \nUPDATE ${this.qs(mj_core_schema(), 'EntityField')}
6927
+ }
6928
+ const setClauses = [];
6929
+ if (update.Category !== undefined) {
6930
+ setClauses.push(`Category = '${update.Category.replace(/'/g, "''")}'`);
6931
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, field.Name, 'Category', update.Category);
6932
+ }
6933
+ if (update.GeneratedFormSection !== undefined) {
6934
+ setClauses.push(`GeneratedFormSection = 'Category'`);
6935
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, field.Name, 'GeneratedFormSection', 'Category');
6936
+ }
6937
+ if (update.DisplayName !== undefined) {
6938
+ setClauses.push(`DisplayName = '${update.DisplayName.replace(/'/g, "''")}'`);
6939
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, field.Name, 'DisplayName', update.DisplayName);
6940
+ }
6941
+ if (update.ExtendedType !== undefined) {
6942
+ const extVal = update.ExtendedType === null ? 'NULL' : `'${update.ExtendedType.replace(/'/g, "''")}'`;
6943
+ setClauses.push(`ExtendedType = ${extVal}`);
6944
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, field.Name, 'ExtendedType', update.ExtendedType);
6945
+ }
6946
+ if (update.CodeType !== undefined) {
6947
+ const codeVal = update.CodeType === null ? 'NULL' : `'${update.CodeType.replace(/'/g, "''")}'`;
6948
+ setClauses.push(`CodeType = ${codeVal}`);
6949
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, field.Name, 'CodeType', update.CodeType);
6950
+ }
6951
+ if (setClauses.length > 0) {
6952
+ sqlStatements.push(`\n-- UPDATE Entity Field Category Info ${entity.Name}.${field.Name} \nUPDATE ${this.qs(mj_core_schema(), 'EntityField')}
6731
6953
  SET
6732
6954
  ${setClauses.join(',\n ')}
6733
6955
  WHERE
6734
- ID = '${field.ID}' AND AutoUpdateCategory = ${this.boolLit(true)}`);
6735
- }
6736
- }
6737
- else if (!field) {
6738
- logError(`Form layout returned invalid fieldName: '${fieldCategory.fieldName}' not found in entity`);
6956
+ ID = '${field.ID}'`);
6739
6957
  }
6740
6958
  }
6741
6959
  if (sqlStatements.length > 0) {
@@ -6750,7 +6968,7 @@ WHERE
6750
6968
  /**
6751
6969
  * Sets the entity icon if the entity doesn't already have one.
6752
6970
  */
6753
- async applyEntityIcon(pool, entityId, entityIcon) {
6971
+ async applyEntityIcon(pool, entityId, entityIcon, entityName) {
6754
6972
  if (!entityIcon || entityIcon.trim().length === 0)
6755
6973
  return;
6756
6974
  const checkSQL = `SELECT Icon FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE ID = '${entityId}'`;
@@ -6767,6 +6985,9 @@ WHERE
6767
6985
  try {
6768
6986
  await this.LogSQLAndExecute(pool, updateSQL, `Set entity icon to ${entityIcon}`, false);
6769
6987
  logStatus(` Set entity icon: ${entityIcon}`);
6988
+ if (entityName) {
6989
+ DecisionMetadataWriter.Instance.recordEntityDecision(entityName, 'Icon', entityIcon);
6990
+ }
6770
6991
  }
6771
6992
  catch (ex) {
6772
6993
  logError('Error Applying Entity Icon', ex);
@@ -6777,32 +6998,49 @@ WHERE
6777
6998
  /**
6778
6999
  * Upserts FieldCategoryInfo (new format) and FieldCategoryIcons (legacy format) in EntitySetting.
6779
7000
  */
6780
- async applyCategoryInfoSettings(pool, entityId, categoryInfo) {
7001
+ async applyCategoryInfoSettings(pool, entityId, categoryInfo, entityName) {
6781
7002
  if (!categoryInfo || Object.keys(categoryInfo).length === 0)
6782
7003
  return;
6783
- const infoJSON = JSON.stringify(categoryInfo).replace(/'/g, "''");
7004
+ if (entityName) {
7005
+ DecisionMetadataWriter.Instance.recordEntitySetting(entityName, 'FieldCategoryInfo', categoryInfo);
7006
+ }
7007
+ const canonicalInfo = canonicalJSONStringify(categoryInfo, 2);
7008
+ const infoJSON = canonicalInfo.replace(/'/g, "''");
7009
+ const ENTITY_SETTING_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
6784
7010
  // Upsert FieldCategoryInfo (new format)
6785
- const checkNewSQL = `SELECT ${this.qi('ID')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
7011
+ const checkNewSQL = `SELECT ${this.qi('ID')}, ${this.qi('Value')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
6786
7012
  const existingNew = await this.runQuery(pool, checkNewSQL);
6787
7013
  if (existingNew.recordset.length > 0) {
7014
+ let isMatch = false;
6788
7015
  try {
6789
- await this.LogSQLAndExecute(pool, `
6790
- UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
6791
- SET ${this.qi('Value')} = '${infoJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
6792
- WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
6793
- `, `Update FieldCategoryInfo setting for entity`, false);
7016
+ const rawVal = existingNew.recordset[0].Value;
7017
+ const parsed = typeof rawVal === 'string' ? JSON.parse(rawVal) : rawVal;
7018
+ isMatch = deepEqualJSON(parsed, categoryInfo);
6794
7019
  }
6795
- catch (ex) {
6796
- logError('Error Applying Category Info Settings: Part 1', ex);
7020
+ catch {
7021
+ isMatch = false;
7022
+ }
7023
+ if (!isMatch) {
7024
+ try {
7025
+ await this.LogSQLAndExecute(pool, `
7026
+ UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
7027
+ SET ${this.qi('Value')} = '${infoJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
7028
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
7029
+ `, `Update FieldCategoryInfo setting for entity`, false);
7030
+ }
7031
+ catch (ex) {
7032
+ logError('Error Applying Category Info Settings: Part 1', ex);
7033
+ }
6797
7034
  }
6798
7035
  }
6799
7036
  else {
6800
- const newId = uuidv4();
7037
+ const newId = uuidv5(`${entityId}|FieldCategoryInfo`, ENTITY_SETTING_NAMESPACE);
6801
7038
  try {
6802
- await this.LogSQLAndExecute(pool, `
6803
- 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)})
6804
- VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})
6805
- `, `Insert FieldCategoryInfo setting for entity`, false);
7039
+ const checkQuery = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
7040
+ const insertSQL = `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)})
7041
+ VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})`;
7042
+ const sSQL = this.conditionalInsert(checkQuery, insertSQL);
7043
+ await this.LogSQLAndExecute(pool, sSQL, `Insert FieldCategoryInfo setting for entity`, false);
6806
7044
  }
6807
7045
  catch (ex) {
6808
7046
  logError('Error Applying Category Info Settings: Part 2', ex);
@@ -6815,28 +7053,44 @@ WHERE
6815
7053
  iconsOnly[category] = info.icon;
6816
7054
  }
6817
7055
  }
6818
- const iconsJSON = JSON.stringify(iconsOnly).replace(/'/g, "''");
6819
- const checkLegacySQL = `SELECT ${this.qi('ID')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
7056
+ if (entityName) {
7057
+ DecisionMetadataWriter.Instance.recordEntitySetting(entityName, 'FieldCategoryIcons', iconsOnly);
7058
+ }
7059
+ const canonicalIcons = canonicalJSONStringify(iconsOnly, 2);
7060
+ const iconsJSON = canonicalIcons.replace(/'/g, "''");
7061
+ const checkLegacySQL = `SELECT ${this.qi('ID')}, ${this.qi('Value')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
6820
7062
  const existingLegacy = await this.runQuery(pool, checkLegacySQL);
6821
7063
  if (existingLegacy.recordset.length > 0) {
7064
+ let isMatch = false;
6822
7065
  try {
6823
- await this.LogSQLAndExecute(pool, `
6824
- UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
6825
- SET ${this.qi('Value')} = '${iconsJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
6826
- WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
6827
- `, `Update FieldCategoryIcons setting (legacy)`, false);
7066
+ const rawVal = existingLegacy.recordset[0].Value;
7067
+ const parsed = typeof rawVal === 'string' ? JSON.parse(rawVal) : rawVal;
7068
+ isMatch = deepEqualJSON(parsed, iconsOnly);
6828
7069
  }
6829
- catch (ex) {
6830
- logError('Error Applying Category Info Settings: Part 3', ex);
7070
+ catch {
7071
+ isMatch = false;
7072
+ }
7073
+ if (!isMatch) {
7074
+ try {
7075
+ await this.LogSQLAndExecute(pool, `
7076
+ UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
7077
+ SET ${this.qi('Value')} = '${iconsJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
7078
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
7079
+ `, `Update FieldCategoryIcons setting (legacy)`, false);
7080
+ }
7081
+ catch (ex) {
7082
+ logError('Error Applying Category Info Settings: Part 3', ex);
7083
+ }
6831
7084
  }
6832
7085
  }
6833
7086
  else {
6834
- const newId = uuidv4();
7087
+ const newId = uuidv5(`${entityId}|FieldCategoryIcons`, ENTITY_SETTING_NAMESPACE);
6835
7088
  try {
6836
- await this.LogSQLAndExecute(pool, `
6837
- 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)})
6838
- VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})
6839
- `, `Insert FieldCategoryIcons setting (legacy)`, false);
7089
+ const checkQuery = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
7090
+ const insertSQL = `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)})
7091
+ VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})`;
7092
+ const sSQL = this.conditionalInsert(checkQuery, insertSQL);
7093
+ await this.LogSQLAndExecute(pool, sSQL, `Insert FieldCategoryIcons setting (legacy)`, false);
6840
7094
  }
6841
7095
  catch (ex) {
6842
7096
  logError('Error Applying Category Info Settings: Part 4', ex);
@@ -6847,7 +7101,7 @@ WHERE
6847
7101
  * Applies entity importance analysis to MJApplicationEntity records.
6848
7102
  * Only called for NEW entities to set DefaultForNewUser.
6849
7103
  */
6850
- async applyEntityImportance(pool, entityId, importance) {
7104
+ async applyEntityImportance(pool, entityId, importance, entityName) {
6851
7105
  const updateSQL = `
6852
7106
  UPDATE ${this.qs(mj_core_schema(), 'ApplicationEntity')}
6853
7107
  SET ${this.qi('DefaultForNewUser')} = ${this.boolLit(importance.defaultForNewUser)}, ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
@@ -6857,6 +7111,9 @@ WHERE
6857
7111
  await this.LogSQLAndExecute(pool, updateSQL, `Set DefaultForNewUser=${importance.defaultForNewUser} for NEW entity (category: ${importance.entityCategory}, confidence: ${importance.confidence})`, false);
6858
7112
  logStatus(` Entity importance (NEW Entity): ${importance.entityCategory} (defaultForNewUser: ${importance.defaultForNewUser}, confidence: ${importance.confidence})`);
6859
7113
  logStatus(` Reasoning: ${importance.reasoning}`);
7114
+ if (entityName) {
7115
+ DecisionMetadataWriter.Instance.recordApplicationEntityDecision(entityName, '', 'DefaultForNewUser', importance.defaultForNewUser);
7116
+ }
6860
7117
  }
6861
7118
  catch (ex) {
6862
7119
  logError('Error Applying Entity Importance', ex);