@memberjunction/codegen-lib 6.1.0-edge.4 → 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 (136) 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 +216 -12
  17. package/dist/Config/config.d.ts.map +1 -1
  18. package/dist/Config/config.js +88 -6
  19. package/dist/Config/config.js.map +1 -1
  20. package/dist/Config/schema-output.d.ts +33 -0
  21. package/dist/Config/schema-output.d.ts.map +1 -0
  22. package/dist/Config/schema-output.js +52 -0
  23. package/dist/Config/schema-output.js.map +1 -0
  24. package/dist/Database/codeGenDatabaseProvider.d.ts +68 -1
  25. package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
  26. package/dist/Database/codeGenDatabaseProvider.js +142 -3
  27. package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
  28. package/dist/Database/dbSchema.js +2 -1
  29. package/dist/Database/dbSchema.js.map +1 -1
  30. package/dist/Database/decision-metadata-format.d.ts +124 -0
  31. package/dist/Database/decision-metadata-format.d.ts.map +1 -0
  32. package/dist/Database/decision-metadata-format.js +279 -0
  33. package/dist/Database/decision-metadata-format.js.map +1 -0
  34. package/dist/Database/decision-metadata-writer.d.ts +48 -0
  35. package/dist/Database/decision-metadata-writer.d.ts.map +1 -0
  36. package/dist/Database/decision-metadata-writer.js +482 -0
  37. package/dist/Database/decision-metadata-writer.js.map +1 -0
  38. package/dist/Database/entity-field-change-tracking.d.ts +53 -0
  39. package/dist/Database/entity-field-change-tracking.d.ts.map +1 -0
  40. package/dist/Database/entity-field-change-tracking.js +120 -0
  41. package/dist/Database/entity-field-change-tracking.js.map +1 -0
  42. package/dist/Database/entity-level-tree-cycles.d.ts.map +1 -1
  43. package/dist/Database/entity-level-tree-cycles.js +3 -2
  44. package/dist/Database/entity-level-tree-cycles.js.map +1 -1
  45. package/dist/Database/exclude-tables.d.ts +26 -0
  46. package/dist/Database/exclude-tables.d.ts.map +1 -0
  47. package/dist/Database/exclude-tables.js +38 -0
  48. package/dist/Database/exclude-tables.js.map +1 -0
  49. package/dist/Database/field-metadata-lock.d.ts +47 -0
  50. package/dist/Database/field-metadata-lock.d.ts.map +1 -0
  51. package/dist/Database/field-metadata-lock.js +159 -0
  52. package/dist/Database/field-metadata-lock.js.map +1 -0
  53. package/dist/Database/manage-metadata.d.ts +142 -42
  54. package/dist/Database/manage-metadata.d.ts.map +1 -1
  55. package/dist/Database/manage-metadata.js +625 -287
  56. package/dist/Database/manage-metadata.js.map +1 -1
  57. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +2 -2
  58. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
  59. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +90 -41
  60. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
  61. package/dist/Database/providers/postgresql/metadataSupportObjects.js +31 -8
  62. package/dist/Database/providers/postgresql/metadataSupportObjects.js.map +1 -1
  63. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +3 -1
  64. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
  65. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +58 -23
  66. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
  67. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js +15 -2
  68. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js.map +1 -1
  69. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.test.js +17 -0
  70. package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.test.js.map +1 -1
  71. package/dist/Database/sql_codegen.d.ts +24 -0
  72. package/dist/Database/sql_codegen.d.ts.map +1 -1
  73. package/dist/Database/sql_codegen.js +108 -29
  74. package/dist/Database/sql_codegen.js.map +1 -1
  75. package/dist/Manifest/GenerateClassRegistrationsManifest.d.ts +22 -0
  76. package/dist/Manifest/GenerateClassRegistrationsManifest.d.ts.map +1 -1
  77. package/dist/Manifest/GenerateClassRegistrationsManifest.js +115 -7
  78. package/dist/Manifest/GenerateClassRegistrationsManifest.js.map +1 -1
  79. package/dist/Misc/action_subclasses_codegen.js +2 -2
  80. package/dist/Misc/action_subclasses_codegen.js.map +1 -1
  81. package/dist/Misc/advanced_generation.d.ts +7 -5
  82. package/dist/Misc/advanced_generation.d.ts.map +1 -1
  83. package/dist/Misc/advanced_generation.js +34 -23
  84. package/dist/Misc/advanced_generation.js.map +1 -1
  85. package/dist/Misc/codegen-reporter.d.ts +5 -0
  86. package/dist/Misc/codegen-reporter.d.ts.map +1 -1
  87. package/dist/Misc/codegen-reporter.js +9 -2
  88. package/dist/Misc/codegen-reporter.js.map +1 -1
  89. package/dist/Misc/emit-stats.d.ts +28 -0
  90. package/dist/Misc/emit-stats.d.ts.map +1 -0
  91. package/dist/Misc/emit-stats.js +43 -0
  92. package/dist/Misc/emit-stats.js.map +1 -0
  93. package/dist/Misc/entity_subclasses_codegen.d.ts +20 -2
  94. package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
  95. package/dist/Misc/entity_subclasses_codegen.js +120 -51
  96. package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
  97. package/dist/Misc/file-write.d.ts +15 -0
  98. package/dist/Misc/file-write.d.ts.map +1 -0
  99. package/dist/Misc/file-write.js +33 -0
  100. package/dist/Misc/file-write.js.map +1 -0
  101. package/dist/Misc/graphql_server_codegen.d.ts +12 -2
  102. package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
  103. package/dist/Misc/graphql_server_codegen.js +72 -16
  104. package/dist/Misc/graphql_server_codegen.js.map +1 -1
  105. package/dist/Misc/remote_operations_codegen.d.ts.map +1 -1
  106. package/dist/Misc/remote_operations_codegen.js +4 -3
  107. package/dist/Misc/remote_operations_codegen.js.map +1 -1
  108. package/dist/Misc/runCommand.d.ts +5 -0
  109. package/dist/Misc/runCommand.d.ts.map +1 -1
  110. package/dist/Misc/runCommand.js +43 -17
  111. package/dist/Misc/runCommand.js.map +1 -1
  112. package/dist/Misc/schema-emit.d.ts +123 -0
  113. package/dist/Misc/schema-emit.d.ts.map +1 -0
  114. package/dist/Misc/schema-emit.js +221 -0
  115. package/dist/Misc/schema-emit.js.map +1 -0
  116. package/dist/Misc/sql_logging.d.ts +63 -8
  117. package/dist/Misc/sql_logging.d.ts.map +1 -1
  118. package/dist/Misc/sql_logging.js +169 -42
  119. package/dist/Misc/sql_logging.js.map +1 -1
  120. package/dist/Misc/util.d.ts +12 -0
  121. package/dist/Misc/util.d.ts.map +1 -1
  122. package/dist/Misc/util.js +71 -8
  123. package/dist/Misc/util.js.map +1 -1
  124. package/dist/index.d.ts +8 -0
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +8 -0
  127. package/dist/index.js.map +1 -1
  128. package/dist/plugins/index.d.ts +3 -0
  129. package/dist/plugins/index.d.ts.map +1 -1
  130. package/dist/plugins/index.js +28 -1
  131. package/dist/plugins/index.js.map +1 -1
  132. package/dist/runCodeGen.d.ts +20 -2
  133. package/dist/runCodeGen.d.ts.map +1 -1
  134. package/dist/runCodeGen.js +186 -33
  135. package/dist/runCodeGen.js.map +1 -1
  136. 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,124 @@ 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
+ }
368
+ static { this._deletedEntitySchemaList = []; }
369
+ /**
370
+ * Schemas that lost an entity during this run.
371
+ *
372
+ * Deletion cannot be reported the way new/modified entities are. Those lists carry entity
373
+ * NAMES, which downstream code resolves to a schema by looking the entity up in live
374
+ * metadata — and a deleted entity is, by then, no longer there to look up. So the schema is
375
+ * captured here at deletion time instead.
376
+ *
377
+ * Without this the schema is never marked dirty, its per-schema file is never rebuilt, and
378
+ * the dead class keeps a live `@RegisterClass` registration on disk. The old monolith could
379
+ * not drift this way: it was rewritten in full every run.
380
+ */
381
+ static get deletedEntitySchemaList() {
382
+ return this._deletedEntitySchemaList;
383
+ }
286
384
  static { this._entitiesRequiringViewRegen = []; }
287
385
  /**
288
386
  * Entities that had late-phase changes requiring their base views to be
@@ -923,29 +1021,63 @@ export class ManageMetadataBase {
923
1021
  logStatus(` > Entities config: "${ec.SchemaName}.${ec.BaseTable}" has no attributes to set — skipping`);
924
1022
  continue;
925
1023
  }
926
- // 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(', ');
927
1026
  const entityResult = await this.runQueryWithParams(pool, `
928
- ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(schema, 'vwEntities')}
1027
+ ${this.selectTop(1, `ID, Name, ${attrCols}`, `FROM ${this.qs(schema, 'vwEntities')}
929
1028
  WHERE BaseTable = @BaseTable AND SchemaName = @SchemaName`)}
930
1029
  `, { 'BaseTable': ec.BaseTable, 'SchemaName': ec.SchemaName });
931
1030
  if (entityResult.recordset.length === 0) {
932
1031
  logError(` > Entities config: entity for "${ec.SchemaName}.${ec.BaseTable}" not found — skipping`);
933
1032
  continue;
934
1033
  }
935
- const entityId = entityResult.recordset[0].ID;
936
- const entityName = entityResult.recordset[0].Name;
937
- // Build a parameterized UPDATE with one SET clause per attribute
938
- const queryParams = { 'EntityID': entityId };
939
- 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 = [];
940
1039
  for (const [key, value] of attrs) {
941
- const paramName = `attr_${key}`;
942
- // Pass booleans through — both mssql and pg drivers handle native boolean values
943
- const sqlValue = value;
944
- queryParams[paramName] = sqlValue;
945
- 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;
946
1072
  }
947
- await this.runQueryWithParams(pool, `UPDATE ${this.qs(schema, 'Entity')} SET ${setClauses.join(', ')} WHERE ID = @EntityID`, queryParams);
948
- 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(', ');
949
1081
  logStatus(` > Entities config: Set ${attrSummary} on "${entityName}"`);
950
1082
  updatedCount++;
951
1083
  }
@@ -1107,9 +1239,10 @@ export class ManageMetadataBase {
1107
1239
  const tableSQL = this.dbProvider.generateMaterializedTableSQL(matSchema, tableName, columns);
1108
1240
  const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(matSchema, viewName, tableName);
1109
1241
  // includeBatchSeparator: each is a single GO-free batch (executed via ds.query), but the
1110
- // migration file needs a GO between statements for Flyway/sqlcmd.
1111
- await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for base-view materialization of entity ${entity.Name}`, false, true);
1112
- 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);
1113
1246
  // 4) Upsert the MJ: Materialized Results row, keyed on (SourceType, SourceEntityID).
1114
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 });
1115
1248
  if (existing.recordset.length > 0) {
@@ -1367,8 +1500,8 @@ export class ManageMetadataBase {
1367
1500
  }
1368
1501
  const tableSQL = this.dbProvider.generateMaterializedTableSQL(coreSchema, tableName, analysis.columns);
1369
1502
  const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(coreSchema, viewName, tableName);
1370
- await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true);
1371
- 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);
1372
1505
  // 3) Mint the read-only Virtual Entity over the wrapper view (idempotent by view).
1373
1506
  const existingVE = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'vwEntities')} WHERE BaseView = @V AND SchemaName = @S`, { V: viewName, S: coreSchema });
1374
1507
  let generatedEntityId = existingVE.recordset[0]?.ID;
@@ -1948,8 +2081,8 @@ export class ManageMetadataBase {
1948
2081
  * INTEGRITY CHECK — in a well-formed entity every base (non-virtual) field sequences BEFORE the
1949
2082
  * virtual/related fields, so the EntityField order matches the base view's `SELECT [base].*, <joins>`
1950
2083
  * column output. The positional save-capture in the data providers (e.g. SQLServerDataProvider's
1951
- * @ResultTable) relies on that alignment. CodeGen assigns newly-discovered columns a temporary
1952
- * `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
1953
2086
  * a base column is left sequenced AFTER a virtual field, the save-capture would mis-route values by
1954
2087
  * position. The providers now compensate (saves stay correct), but the metadata is still wrong — so we
1955
2088
  * scan for it after every metadata pass and log a prominent warning to drive the root cause out over time.
@@ -1962,7 +2095,7 @@ export class ManageMetadataBase {
1962
2095
  const sSQL = `SELECT ${this.qi('EntityID')}, ${this.qi('Entity')}, ${this.qi('Name')}, ${this.qi('Sequence')}, ${this.qi('IsVirtual')}
1963
2096
  FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
1964
2097
  ${excl}
1965
- ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}`;
2098
+ ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}, ${this.qi('Name')}`;
1966
2099
  const result = await this.runQuery(pool, sSQL);
1967
2100
  const rows = result.recordset;
1968
2101
  const isV = (v) => v === true || v === 1 || v === '1';
@@ -1995,7 +2128,7 @@ export class ManageMetadataBase {
1995
2128
  ` Healthy entities sequence ALL base (non-virtual) fields before virtual/related fields, so EntityField\n` +
1996
2129
  ` order matches the base view ([base].* then joins) that the data-provider save-capture relies on. When a\n` +
1997
2130
  ` base column sequences after a virtual field, the capture re-orders defensively (saves stay correct), but\n` +
1998
- ` 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` +
1999
2132
  ` renumbered. Fix the field's Sequence at the source so it sorts with the base columns.\n` +
2000
2133
  ` Affected (entity: base fields out of order):\n`;
2001
2134
  for (const o of offenders)
@@ -2448,11 +2581,8 @@ export class ManageMetadataBase {
2448
2581
  else {
2449
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
2450
2583
  newEntityFieldUUID = this.createNewUUID();
2451
- // Compute a non-colliding sequence by querying MAX(Sequence) for this entity. The
2452
- // ordinal `fieldSequence` (column index in the view) is correct for stable ordering
2453
- // when nothing exists yet, but a partial prior run could have left rows at the same
2454
- // ordinal under this EntityID — UQ_EntityField_EntityID_Sequence then fires on PG.
2455
- 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.
2456
2586
  const q = (n) => this.qi(n);
2457
2587
  const sqlAdd = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
2458
2588
  ${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
@@ -2461,10 +2591,11 @@ export class ManageMetadataBase {
2461
2591
  ${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
2462
2592
  VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${String(veField.FieldName).replace(/'/g, "''")}', '${veField.Type}', ${this.boolLit(veField.AllowsNull)},
2463
2593
  ${veField.Length}, ${veField.Precision}, ${veField.Scale},
2464
- ${safeSequence}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
2594
+ ${this.applyTimeEntityFieldSequenceSQL(entity.ID)}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
2465
2595
  ${this.utcNow()}, ${this.utcNow()}
2466
2596
  )`;
2467
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);
2468
2599
  didUpdate = true;
2469
2600
  }
2470
2601
  }
@@ -2486,14 +2617,16 @@ export class ManageMetadataBase {
2486
2617
  if (virtualEntities.length === 0) {
2487
2618
  return;
2488
2619
  }
2489
- // 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.
2490
2623
  const availableEntities = md.Entities
2491
- .filter(e => !e.VirtualEntity && e.PrimaryKeys.length > 0)
2624
+ .filter(e => !e.VirtualEntity && e.PrimaryKeys.length === 1)
2492
2625
  .map(e => ({
2493
2626
  Name: e.Name,
2494
2627
  SchemaName: e.SchemaName,
2495
2628
  BaseTable: e.BaseTable,
2496
- 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
2497
2630
  }));
2498
2631
  logStatus(` Decorating virtual entity fields with LLM (${virtualEntities.length} entities)...`);
2499
2632
  let decoratedCount = 0;
@@ -2625,7 +2758,7 @@ export class ManageMetadataBase {
2625
2758
  // Load VE EntityField rows from DB (we need the ID and auto-update flags)
2626
2759
  const schema = mj_core_schema();
2627
2760
  const fieldsSQL = `
2628
- SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, GeneratedFormSection, DisplayName, ExtendedType, CodeType
2761
+ SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, AutoUpdateExtendedType, GeneratedFormSection, DisplayName, ExtendedType, CodeType, ValueListType
2629
2762
  FROM ${this.qs(schema, 'EntityField')}
2630
2763
  WHERE EntityID = '${entity.ID}'
2631
2764
  `;
@@ -2646,16 +2779,18 @@ export class ManageMetadataBase {
2646
2779
  if (fieldCategories.length === 0)
2647
2780
  return false;
2648
2781
  const existingCategories = this.buildExistingCategorySet(dbFields);
2649
- 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 });
2650
2784
  // Apply entity icon if provided
2651
2785
  if (result.entityIcon) {
2652
- await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
2786
+ await this.applyEntityIcon(pool, entity.ID, result.entityIcon, entity.Name);
2653
2787
  }
2654
2788
  // Apply category info settings if provided
2655
2789
  if (result.categoryInfo && Object.keys(result.categoryInfo).length > 0) {
2656
- await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo);
2790
+ await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo, entity.Name);
2657
2791
  }
2658
2792
  logStatus(` Applied categories for VE ${entity.Name} (${fieldCategories.length} fields)`);
2793
+ await DecisionMetadataWriter.Instance.flushEntity(entity);
2659
2794
  return true;
2660
2795
  }
2661
2796
  /**
@@ -2753,9 +2888,12 @@ export class ManageMetadataBase {
2753
2888
  continue;
2754
2889
  }
2755
2890
  const escapedDescription = fd.description.replace(/'/g, "''");
2756
- let setClauses = `Description='${escapedDescription}'`;
2757
- // Apply extended type if provided and valid
2758
- 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)) {
2759
2897
  const validExtendedType = this.validateExtendedType(fd.extendedType);
2760
2898
  if (validExtendedType) {
2761
2899
  setClauses += `, ExtendedType='${validExtendedType}'`;
@@ -2772,12 +2910,9 @@ export class ManageMetadataBase {
2772
2910
  return true;
2773
2911
  }
2774
2912
  /**
2775
- * 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}.
2776
2914
  */
2777
- static { this.VALID_EXTENDED_TYPES = new Set([
2778
- 'Code', 'Email', 'FaceTime', 'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
2779
- 'GeoCity', 'GeoPostalCode', 'GeoAddress', 'MSTeams', 'Other', 'SIP', 'SMS', 'Skype', 'Tel', 'URL', 'WhatsApp', 'ZoomMtg'
2780
- ]); }
2915
+ static { this.VALID_EXTENDED_TYPES = new Set(EntityFieldInfo.ExtendedTypes); }
2781
2916
  static { this.EXTENDED_TYPE_ALIASES = {
2782
2917
  'phone': 'Tel',
2783
2918
  'telephone': 'Tel',
@@ -2807,6 +2942,17 @@ export class ManageMetadataBase {
2807
2942
  'zoom': 'ZoomMtg',
2808
2943
  'whatsapp': 'WhatsApp',
2809
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',
2810
2956
  }; }
2811
2957
  /**
2812
2958
  * Validates an LLM-suggested ExtendedType against the allowed values in EntityField.
@@ -2914,12 +3060,8 @@ export class ManageMetadataBase {
2914
3060
  else {
2915
3061
  // Create new virtual field record for this parent field
2916
3062
  const newFieldID = this.createNewUUID();
2917
- // Use high sequence — will be reordered by updateExistingEntityFieldsFromSchema.
2918
- // Query MAX(Sequence) at insert time so we never collide with an existing row
2919
- // (e.g. partial prior run left a record at the candidate ordinal under this
2920
- // EntityID — UQ_EntityField_EntityID_Sequence then fires on PG).
2921
- const candidate = 100000 + parentFields.indexOf(parentField);
2922
- const sequence = await this.nextAvailableEntityFieldSequence(pool, childEntity.ID, candidate);
3063
+ // Apply-time Sequence (see applyTimeEntityFieldSequenceSQL); reordered by
3064
+ // updateExistingEntityFieldsFromSchema afterwards.
2923
3065
  const q = (n) => this.qi(n);
2924
3066
  const sqlInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
2925
3067
  ${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
@@ -2931,9 +3073,10 @@ export class ManageMetadataBase {
2931
3073
  '${newFieldID}', '${childEntity.ID}', '${parentField.Name}',
2932
3074
  '${parentField.Type}', ${this.boolLit(parentField.AllowsNull)},
2933
3075
  ${parentField.Length}, ${parentField.Precision}, ${parentField.Scale},
2934
- ${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)},
2935
3077
  ${this.utcNow()}, ${this.utcNow()})`;
2936
3078
  await this.LogSQLAndExecute(pool, sqlInsert, `Create IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
3079
+ ManageMetadataBase.registerNewField(childEntity.ID, parentField.Name);
2937
3080
  bUpdated = true;
2938
3081
  }
2939
3082
  }
@@ -3006,7 +3149,7 @@ export class ManageMetadataBase {
3006
3149
  RelatedEntityID IS NOT NULL AND
3007
3150
  IsVirtual = ${this.boolLit(false)} AND
3008
3151
  EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
3009
- ORDER BY RelatedEntityID`;
3152
+ ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
3010
3153
  const entityFieldsResult = await this.runQuery(pool, sSQL);
3011
3154
  const entityFields = entityFieldsResult.recordset;
3012
3155
  // Get the relationship counts for each entity
@@ -3184,7 +3327,7 @@ export class ManageMetadataBase {
3184
3327
  RelatedEntityID IS NOT NULL AND
3185
3328
  IsVirtual = ${this.boolLit(false)} AND
3186
3329
  EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
3187
- ORDER BY RelatedEntityID`;
3330
+ ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
3188
3331
  const entityFieldsResult = await this.runQuery(pool, sSQL);
3189
3332
  const entityFields = entityFieldsResult.recordset;
3190
3333
  const sSQLRelationship = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityRelationship')}`;
@@ -3260,6 +3403,11 @@ export class ManageMetadataBase {
3260
3403
  const sqlDelete = this.dbProvider.callRoutineSQL(mj_core_schema(), 'spDeleteEntityWithCoreDependencies', [`'${e.ID}'`], ['EntityID'], true);
3261
3404
  await this.LogSQLAndExecute(pool, sqlDelete, `SQL text to remove entity ${e.Name}`);
3262
3405
  logStatus(` > Removed metadata for table ${e.SchemaName}.${e.BaseTable}`);
3406
+ // Recorded only after the delete succeeds, so a failed removal (logged below for
3407
+ // an admin to handle) does not mark a schema dirty for an entity still present.
3408
+ if (e.SchemaName && e.SchemaName.trim().length > 0) {
3409
+ ManageMetadataBase._deletedEntitySchemaList.push(e.SchemaName.trim());
3410
+ }
3263
3411
  // next up we need to remove the spCreate, spDelete, spUpdate, BaseView, and FullTextSearchFunction, if provided.
3264
3412
  // We only remoe these artifcacts when they are generated which is info we have in the BaseViewGenerated, spCreateGenerated, etc. fields
3265
3413
  await this.checkDropSQLObject(pool, e.BaseViewGenerated, 'view', e.SchemaName, e.BaseView);
@@ -3409,7 +3557,7 @@ export class ManageMetadataBase {
3409
3557
  // AN: 14-June-2025 - See note below about the new order of these steps, this must
3410
3558
  // happen before we update existing entity fields from schema.
3411
3559
  const step2StartTime = new Date();
3412
- 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)) {
3413
3561
  logError('Error creating new entity fields from schema');
3414
3562
  bSuccess = false;
3415
3563
  }
@@ -3426,7 +3574,7 @@ export class ManageMetadataBase {
3426
3574
  }
3427
3575
  // AN: 14-June-2025 - we are now running this AFTER we create new entity fields from schema
3428
3576
  // which results in the same pattern of behavior as migrations where we first create new fields
3429
- // 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
3430
3578
  // we align them properly in sequential order from 1+ via this method below.
3431
3579
  const step3StartTime = new Date();
3432
3580
  if (!await this.updateExistingEntityFieldsFromSchema(pool, excludeSchemas, scopedEntityIDs)) {
@@ -3565,6 +3713,9 @@ export class ManageMetadataBase {
3565
3713
  try {
3566
3714
  logStatus(` Found ${configInfo.additionalSchemaInfo}, applying soft PK/FK configuration...`);
3567
3715
  const config = ManageMetadataBase.getSoftPKFKConfig();
3716
+ if (!config) {
3717
+ return true;
3718
+ }
3568
3719
  let totalPKs = 0;
3569
3720
  let totalFKs = 0;
3570
3721
  const schema = mj_core_schema();
@@ -3589,6 +3740,18 @@ export class ManageMetadataBase {
3589
3740
  const primaryKeys = table.PrimaryKey || [];
3590
3741
  if (primaryKeys.length > 0) {
3591
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
+ }
3592
3755
  const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
3593
3756
  SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
3594
3757
  ${this.qi('IsPrimaryKey')} = ${this.boolLit(true)},
@@ -3614,11 +3777,29 @@ export class ManageMetadataBase {
3614
3777
  continue;
3615
3778
  }
3616
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
+ }
3617
3797
  const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
3618
3798
  SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
3619
3799
  ${this.qi('RelatedEntityID')} = '${relatedEntityId}',
3620
3800
  ${this.qi('RelatedEntityFieldName')} = '${fk.RelatedField}',
3621
- ${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)}
3801
+ ${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)},
3802
+ ${this.qi('AutoUpdateRelatedEntityInfo')} = ${this.boolLit(false)}
3622
3803
  WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
3623
3804
  const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft FK for ${tableSchema}.${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
3624
3805
  if (result !== null) {
@@ -3651,7 +3832,7 @@ export class ManageMetadataBase {
3651
3832
  return true;
3652
3833
  try {
3653
3834
  // Load all existing EntityFieldValue rows for dedup checking
3654
- 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`;
3655
3836
  const allEFVResult = await this.runQuery(pool, efvSQL);
3656
3837
  const allEntityFieldValues = allEFVResult.recordset;
3657
3838
  let totalApplied = 0;
@@ -3878,7 +4059,8 @@ export class ManageMetadataBase {
3878
4059
  async dropExistingDefaultConstraint(pool, entity, fieldName) {
3879
4060
  try {
3880
4061
  const sqlDropDefaultConstraint = this.dbProvider.dropDefaultConstraintSQL(entity.SchemaName, entity.BaseTable, fieldName);
3881
- 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);
3882
4064
  }
3883
4065
  catch (e) {
3884
4066
  logError(e);
@@ -3897,14 +4079,20 @@ export class ManageMetadataBase {
3897
4079
  if (ag.featureEnabled('EntityDescriptions')) {
3898
4080
  // we have the feature enabled, so let's loop through the new entities and generate descriptions for them
3899
4081
  for (let e of ManageMetadataBase.newEntityList) {
3900
- const dataResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntities')} WHERE Name = '${e}'`);
4082
+ // Every literal in this function is doubled-quote escaped. The description below is
4083
+ // FREE TEXT FROM AN LLM — prose about business entities contains apostrophes as a
4084
+ // matter of course, and one unescaped quote aborts the whole CodeGen run with
4085
+ // "Unclosed quotation mark" (SQL Server) at the very end of an otherwise-complete
4086
+ // pass. '' doubling is correct on both supported dialects.
4087
+ const esc = (s) => s.replace(/'/g, "''");
4088
+ const dataResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntities')} WHERE Name = '${esc(e)}'`);
3901
4089
  const data = dataResult.recordset;
3902
4090
  const fieldsResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} WHERE EntityID='${data[0].ID}'`);
3903
4091
  const fields = fieldsResult.recordset;
3904
4092
  // Use new API to generate entity description
3905
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);
3906
4094
  if (result?.entityDescription && result.entityDescription.length > 0) {
3907
- const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET Description = '${result.entityDescription}' WHERE Name = '${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)}'`;
3908
4096
  await this.LogSQLAndExecute(pool, sSQL, `SQL text to update entity description for entity ${e}`);
3909
4097
  }
3910
4098
  else {
@@ -3988,9 +4176,9 @@ export class ManageMetadataBase {
3988
4176
  *
3989
4177
  * @returns {string} - The SQL statement to retrieve pending entity fields.
3990
4178
  */
3991
- getPendingEntityFieldsSELECTSQL(entityIDs) {
4179
+ getPendingEntityFieldsSELECTSQL(entityIDs, excludeSchemas) {
3992
4180
  const schema = mj_core_schema();
3993
- return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs);
4181
+ return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs, excludeSchemas);
3994
4182
  }
3995
4183
  /**
3996
4184
  * This method builds a SQL Statement that will insert a row into the EntityField table with information about a new field.
@@ -4003,7 +4191,11 @@ export class ManageMetadataBase {
4003
4191
  const isPrimaryKey = n.FieldName?.trim().toLowerCase() === 'id';
4004
4192
  const isForeignKey = n.RelatedEntityID && n.RelatedEntityID.length > 0; // Foreign keys have RelatedEntityID set
4005
4193
  const isNameField = n.FieldName?.trim().toLowerCase() === 'name' || n.IsNameField;
4006
- 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;
4007
4199
  const bDefaultInView = (isNameField || isEarlySequence) && !isPrimaryKey && !isForeignKey;
4008
4200
  const escapedDescription = n.Description ? `'${n.Description.replace(/'/g, "''")}'` : 'NULL';
4009
4201
  let fieldDisplayName = '';
@@ -4030,17 +4222,10 @@ export class ManageMetadataBase {
4030
4222
  : (parsedDefaultValue.trim().toLowerCase() === 'null' ? 'NULL' : `'${escapedParsedDefault}'`);
4031
4223
  const conflictCheck = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ID = '${newEntityFieldUUID}' OR (EntityID = '${n.EntityID}' AND Name = '${n.FieldName}')`;
4032
4224
  const guard = this.dbProvider.wrapInsertWithConflictGuard(conflictCheck);
4033
- // Sequence is the catalog ordinal of this column on the entity's BaseView
4034
- // (`SourceOrdinal` / column_id). Existing rows on the same entity are parked
4035
- // at Sequence+100000 first (see parkEntityFieldSequencesSQL) so this INSERT
4036
- // cannot collide on UQ_EntityField_EntityID_Sequence. Immediately after the
4037
- // batch, manageEntityFields calls spUpdateExistingEntityFieldsFromSchema
4038
- // which rewrites EVERY field on the entity from the live view — including
4039
- // parked rows. That proc must run AFTER views are current (CodeGen Pass 2,
4040
- // after SQL generation). Pass 1 still emits this SQL against whatever the
4041
- // view is at that moment; Pass 2 is the one that matches the finished BaseView.
4042
- const sourceOrdinal = typeof n.SourceOrdinal === 'number' && n.SourceOrdinal > 0 ? n.SourceOrdinal : 1;
4043
- 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);
4044
4229
  return `
4045
4230
  ${guard.prefix}
4046
4231
  INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')}
@@ -4094,7 +4279,7 @@ export class ManageMetadataBase {
4094
4279
  ${n.RelatedEntityID && n.RelatedEntityID.length > 0 ? `'${n.RelatedEntityID}'` : 'NULL'},
4095
4280
  ${n.RelatedEntityFieldName && n.RelatedEntityFieldName.length > 0 ? `'${n.RelatedEntityFieldName}'` : 'NULL'},
4096
4281
  ${this.boolLit(n.IsNameField !== null ? n.IsNameField : false)},
4097
- ${this.boolLit(n.FieldName === 'ID' || n.IsNameField)},
4282
+ ${this.boolLit(Boolean(n.IsNameField))},
4098
4283
  ${this.boolLit(n.RelatedEntityID && n.RelatedEntityID.length > 0)},
4099
4284
  ${this.boolLit(bDefaultInView)},
4100
4285
  ${this.boolLit(n.IsPrimaryKey)},
@@ -4114,31 +4299,20 @@ export class ManageMetadataBase {
4114
4299
  * @param sqlDefaultValue
4115
4300
  * @returns
4116
4301
  */
4117
- /**
4118
- * Park existing EntityField.Sequence values out of the 1..N catalog range so a
4119
- * following INSERT can use the real BaseView column_id without colliding on
4120
- * UQ_EntityField_EntityID_Sequence. The +100000 band is unique-safe; the
4121
- * subsequent spUpdateExistingEntityFieldsFromSchema rewrite brings every row
4122
- * (parked and new) back to live catalog order. Skip rows already parked so a
4123
- * second pass in the same run does not add 100000 twice.
4124
- */
4125
- parkEntityFieldSequencesSQL(entityID) {
4126
- return `UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
4127
- SET ${this.qi('Sequence')} = ${this.qi('Sequence')} + 100000
4128
- WHERE ${this.qi('EntityID')} = '${entityID}'
4129
- AND ${this.qi('Sequence')} < 100000;`;
4130
- }
4131
4302
  parseDefaultValue(sqlDefaultValue) {
4132
4303
  if (sqlDefaultValue === null || sqlDefaultValue === undefined) {
4133
4304
  return null;
4134
4305
  }
4135
4306
  return this.dbProvider.parseColumnDefaultValue(sqlDefaultValue) ?? null;
4136
4307
  }
4137
- async createNewEntityFieldsFromSchema(pool, entityIDs) {
4308
+ async createNewEntityFieldsFromSchema(pool, entityIDs, excludeSchemas) {
4138
4309
  try {
4139
4310
  // entityIDs flows down to the provider's WHERE clause so the inline SELECT
4140
- // narrows to changed entities only when scoped.
4141
- 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);
4142
4316
  const newEntityFieldsResult = await this.runQuery(pool, sSQL);
4143
4317
  const newEntityFields = newEntityFieldsResult.recordset;
4144
4318
  if (newEntityFields.length > 0) {
@@ -4156,18 +4330,14 @@ export class ManageMetadataBase {
4156
4330
  // Batch size is configurable via `metadataInsertBatchSize` (default 250).
4157
4331
  const CHUNK_SIZE = configInfo.metadataInsertBatchSize ?? 250;
4158
4332
  const inserts = [];
4159
- const parkedEntityIDs = new Set();
4160
4333
  for (let i = 0; i < newEntityFields.length; ++i) {
4161
4334
  const n = newEntityFields[i];
4162
4335
  if (n.EntityID !== null && n.EntityID !== undefined && n.EntityID.length > 0) {
4163
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
4164
4337
  // that would have been created violate rules - such as not having an ID column, etc.
4165
- if (!parkedEntityIDs.has(n.EntityID)) {
4166
- inserts.push(this.parkEntityFieldSequencesSQL(n.EntityID));
4167
- parkedEntityIDs.add(n.EntityID);
4168
- }
4169
4338
  const newEntityFieldUUID = this.createNewUUID();
4170
4339
  inserts.push(this.getPendingEntityFieldINSERTSQL(newEntityFieldUUID, n));
4340
+ ManageMetadataBase.registerNewField(n.EntityID, n.FieldName);
4171
4341
  }
4172
4342
  }
4173
4343
  for (let i = 0; i < inserts.length; i += CHUNK_SIZE) {
@@ -4263,11 +4433,20 @@ export class ManageMetadataBase {
4263
4433
  const label = isScoped
4264
4434
  ? `SQL text to update existing entity fields from schema (${entityIDs.length} scoped entities)`
4265
4435
  : `SQL text to update existing entity fields from schema`;
4266
- const result = await this.LogSQLAndExecute(pool, sSQL, label, true);
4267
- // result contains the updated entity fields. Get a distinct list of entity names
4268
- // and add them to the modified entity list if they're not already in there.
4269
- if (result && result.length > 0) {
4270
- 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
+ }
4271
4450
  }
4272
4451
  return true;
4273
4452
  }
@@ -4420,13 +4599,22 @@ export class ManageMetadataBase {
4420
4599
  const sSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFieldsWithCheckConstraints')}${filter}`;
4421
4600
  const resultResult = await this.runQuery(pool, sSQL);
4422
4601
  const result = resultResult.recordset;
4423
- 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`;
4424
4603
  const allEntityFieldValuesResult = await this.runQuery(pool, efvSQL);
4425
4604
  const allEntityFieldValues = allEntityFieldValuesResult.recordset;
4426
- 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`;
4427
4606
  const allEntityFieldsResult = await this.runQuery(pool, efSQL);
4428
4607
  const allEntityFields = allEntityFieldsResult.recordset;
4429
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');
4430
4618
  const columnLevelResults = result.filter((r) => r.EntityFieldID); // get the column level constraints
4431
4619
  const tableLevelResults = result.filter((r) => !r.EntityFieldID); // get the table level constraints
4432
4620
  for (const r of columnLevelResults) {
@@ -4460,9 +4648,8 @@ export class ManageMetadataBase {
4460
4648
  else {
4461
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
4462
4650
  // attempt to use an LLM to do things fancier now
4463
- if (configInfo.advancedGeneration?.enableAdvancedGeneration &&
4464
- configInfo.advancedGeneration?.features.find(f => f.name === 'ParseCheckConstraints' && f.enabled)) {
4465
- // 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
4466
4653
  // run this in parallel
4467
4654
  generationPromises.push(this.runValidationGeneration(r, allEntityFields, !skipDBUpdate, currentUser));
4468
4655
  }
@@ -4471,9 +4658,8 @@ export class ManageMetadataBase {
4471
4658
  }
4472
4659
  // now for the table level constraints run the process for advanced generation
4473
4660
  for (const r of tableLevelResults) {
4474
- if (configInfo.advancedGeneration?.enableAdvancedGeneration &&
4475
- configInfo.advancedGeneration?.features.find(f => f.name === 'ParseCheckConstraints' && f.enabled)) {
4476
- // 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
4477
4663
  // run this in parallel
4478
4664
  generationPromises.push(this.runValidationGeneration(r, allEntityFields, !skipDBUpdate, currentUser));
4479
4665
  }
@@ -4544,9 +4730,9 @@ export class ManageMetadataBase {
4544
4730
  }
4545
4731
  }
4546
4732
  try {
4547
- 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')) {
4548
4735
  // feature is enabled, so let's call the AI to generate a function for us
4549
- const ag = new AdvancedGeneration();
4550
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');
4551
4737
  // Use new API to parse check constraint
4552
4738
  const result = await ag.parseCheckConstraint(constraintDefinition, entityFieldListInfo, generatedValidationFunctionName, currentUser);
@@ -4998,15 +5184,12 @@ export class ManageMetadataBase {
4998
5184
  // process a single new entity
4999
5185
  let newEntityName = await this.createNewEntityName(newEntity, currentUser);
5000
5186
  const newEntityDisplayName = this.createNewEntityDisplayName(newEntity, newEntityName);
5001
- let suffix = '';
5002
- const existingEntity = md.Entities.find(e => e.Name.toLowerCase() === newEntityName.toLowerCase());
5003
- const existingEntityInNewEntityList = ManageMetadataBase.newEntityList.find(e => e === newEntityName); // check the newly created entity list to make sure we didn't create the new entity name along the way in this RUN of CodeGen as it wouldn't yet be in our metadata above
5004
- if (existingEntity || existingEntityInNewEntityList) {
5005
- // the generated name is already in place, so we need another name
5006
- suffix = '__' + newEntity.SchemaName;
5007
- newEntityName = newEntityName + suffix;
5008
- LogError(` >>>> WARNING: Entity name already exists, so using ${newEntityName} instead. If you did not intend for this, please rename the ${newEntity.SchemaName}.${newEntity.TableName} table in the database.`);
5187
+ const { name: uniqueName, suffix: uniqueSuffix } = this.resolveUniqueEntityName(newEntityName, newEntity.SchemaName, md.Entities.map(e => e.Name));
5188
+ if (uniqueName !== newEntityName) {
5189
+ LogError(` >>>> WARNING: Entity name already exists, so using ${uniqueName} instead. If you did not intend for this, please rename the ${newEntity.SchemaName}.${newEntity.TableName} table in the database.`);
5009
5190
  }
5191
+ newEntityName = uniqueName;
5192
+ const suffix = uniqueSuffix;
5010
5193
  const isNewSchema = await this.isSchemaNew(pool, newEntity.SchemaName);
5011
5194
  const newEntityID = this.createNewUUID();
5012
5195
  const sSQLInsert = this.createNewEntityInsertSQL(newEntityID, newEntityName, newEntity, suffix, newEntityDisplayName);
@@ -5562,6 +5745,49 @@ export class ManageMetadataBase {
5562
5745
  }
5563
5746
  return revoked;
5564
5747
  }
5748
+ /**
5749
+ * Pick an entity name that is actually free, disambiguating with the schema name and, if
5750
+ * that is still taken, a counter.
5751
+ *
5752
+ * Entity names are generated from the table name with trailing discriminators stripped, so
5753
+ * distinct tables routinely generate the SAME name — NetSuite's customlist72, customlist74,
5754
+ * customlist160, customlist436, customlist534 and customlist873 all generate "Custom Lists".
5755
+ *
5756
+ * The previous logic appended the schema suffix ONCE and assumed the result was unique. It
5757
+ * is not: the first table took "Custom Lists", and every table after it appended the same
5758
+ * "__netsuite" and produced the identical "Custom Lists__netsuite". The second was a
5759
+ * duplicate-key failure on UQ_Entity_Name and the rest were never created — a silent loss of
5760
+ * 8 entities on one NetSuite tenant, reported only as repeated identical INSERT errors.
5761
+ *
5762
+ * Comparison is case-INSENSITIVE on both sides, matching UQ_Entity_Name's collation. The old
5763
+ * in-run check used an exact `===` while the metadata check beside it lowercased, so two
5764
+ * names differing only in case read as free and then collided on INSERT.
5765
+ *
5766
+ * @param desiredName the generated name to place
5767
+ * @param schemaName schema, used for the first disambiguation step
5768
+ * @param takenNames every name already spoken for — existing metadata AND names claimed
5769
+ * earlier in this run (which are not in metadata yet)
5770
+ * @returns the free name and the suffix used to reach it ('' when the name was already free)
5771
+ */
5772
+ resolveUniqueEntityName(desiredName, schemaName, takenNames) {
5773
+ const taken = new Set([...takenNames, ...ManageMetadataBase.newEntityList].map(n => n.toLowerCase()));
5774
+ const isTaken = (candidate) => taken.has(candidate.toLowerCase());
5775
+ if (!isTaken(desiredName)) {
5776
+ return { name: desiredName, suffix: '' };
5777
+ }
5778
+ let suffix = '__' + schemaName;
5779
+ let name = desiredName + suffix;
5780
+ // Bounded: thousands of tables collapsing to one name is a naming problem no suffix can
5781
+ // fix, and looping forever would hide it. Let the INSERT fail loudly instead.
5782
+ const maxAttempts = 1000;
5783
+ let attempt = 2;
5784
+ while (isTaken(name) && attempt <= maxAttempts) {
5785
+ suffix = `__${schemaName}_${attempt}`;
5786
+ name = desiredName + suffix;
5787
+ attempt++;
5788
+ }
5789
+ return { name, suffix };
5790
+ }
5565
5791
  createNewEntityInsertSQL(newEntityUUID, newEntityName, newEntity, newEntitySuffix, newEntityDisplayName) {
5566
5792
  const newEntityDefaults = configInfo.newEntityDefaults;
5567
5793
  const newEntityDescriptionEscaped = newEntity.EntityDescription ? `'${newEntity.EntityDescription.replace(/'/g, "''")}'` : null;
@@ -5694,7 +5920,8 @@ export class ManageMetadataBase {
5694
5920
  WHERE
5695
5921
  ${whereClause}
5696
5922
  ORDER BY
5697
- e.Name
5923
+ e.Name,
5924
+ e.ID
5698
5925
  `;
5699
5926
  const entitiesResult = await this.runQuery(pool, entitiesSQL);
5700
5927
  const entities = entitiesResult.recordset;
@@ -5720,11 +5947,16 @@ export class ManageMetadataBase {
5720
5947
  ef.AutoUpdateIncludeInUserSearchAPI,
5721
5948
  ef.AutoUpdateCategory,
5722
5949
  ef.AutoUpdateDisplayName,
5950
+ ef.AutoUpdateExtendedType,
5951
+ ef.ExtendedType,
5952
+ ef.CodeType,
5953
+ ef.GeneratedFormSection,
5723
5954
  ef.EntityIDFieldName,
5724
5955
  ef.RelatedEntity,
5725
5956
  ef.IsVirtual,
5726
5957
  ef.AllowUpdateAPI,
5727
5958
  ef.IsNameField,
5959
+ ef.ValueListType,
5728
5960
  ef.DefaultInView,
5729
5961
  ef.IncludeInUserSearchAPI,
5730
5962
  ef.UserSearchPredicateAPI,
@@ -5738,7 +5970,9 @@ export class ManageMetadataBase {
5738
5970
  ef.EntityID IN (${entityIds})
5739
5971
  ORDER BY
5740
5972
  ef.EntityID,
5741
- ef.Sequence
5973
+ ef.Sequence,
5974
+ ef.Name,
5975
+ ef.ID
5742
5976
  `;
5743
5977
  const fieldsResult = await this.runQuery(pool, fieldsSQL);
5744
5978
  const allFields = fieldsResult.recordset;
@@ -5753,6 +5987,9 @@ export class ManageMetadataBase {
5753
5987
  WHERE
5754
5988
  es.EntityID IN (${entityIds})
5755
5989
  AND es.Name = 'FieldCategoryInfo'
5990
+ ORDER BY
5991
+ es.EntityID,
5992
+ es.Name
5756
5993
  `;
5757
5994
  const settingsResult = await this.runQuery(pool, settingsSQL);
5758
5995
  const allSettings = settingsResult.recordset;
@@ -5850,16 +6087,26 @@ export class ManageMetadataBase {
5850
6087
  // Fields for this entity — O(1) lookup into the pre-grouped map (keyed on the normalized
5851
6088
  // EntityID) instead of a linear scan of the full pooled array.
5852
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);
5853
6101
  // Determine if this is a new entity (for DefaultForNewUser decision)
5854
6102
  const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
5855
- // Smart Field Identification
5856
- // Only run if at least one field allows auto-update for any of the smart field properties,
5857
- // or if entity-level search/FTS auto-update flags are set
5858
- 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);
5859
6105
  // entity-level auto-update flags come from the raw SQL query, not EntityInfo
5860
6106
  const entityRecord = entity;
5861
- const needsEntitySearchConfig = entityRecord.AutoUpdateAllowUserSearchAPI || entityRecord.AutoUpdateFullTextSearch;
6107
+ const needsEntitySearchConfig = isNewEntity && (Boolean(entityRecord.AutoUpdateAllowUserSearchAPI) || Boolean(entityRecord.AutoUpdateFullTextSearch));
5862
6108
  if (needsFieldAnalysis || needsEntitySearchConfig) {
6109
+ CodeGenReporter.Instance.counter('ai.smartFieldCalls');
5863
6110
  const fieldAnalysis = await ag.identifyFields({
5864
6111
  Name: entity.Name,
5865
6112
  Description: entity.Description,
@@ -5874,13 +6121,17 @@ export class ManageMetadataBase {
5874
6121
  AutoUpdateAllowUserSearchAPI: entityRecord.AutoUpdateAllowUserSearchAPI,
5875
6122
  FullTextSearchEnabled: entityRecord.FullTextSearchEnabled,
5876
6123
  AutoUpdateFullTextSearch: entityRecord.AutoUpdateFullTextSearch,
5877
- }, fields, fieldAnalysis);
6124
+ }, fields, fieldAnalysis, { isNewEntity, newFieldNames, typeReopenedNames });
5878
6125
  }
5879
6126
  }
5880
6127
  // Form Layout Generation
5881
- // Only run if at least one field allows auto-update
5882
- 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);
5883
6133
  if (needsCategoryGeneration) {
6134
+ CodeGenReporter.Instance.counter('ai.formLayoutCalls');
5884
6135
  // Build IS-A parent chain context if this entity has a parent
5885
6136
  const parentChainContext = this.buildParentChainContext(entity, fields);
5886
6137
  const layoutAnalysis = await ag.generateFormLayout({
@@ -5896,6 +6147,8 @@ export class ManageMetadataBase {
5896
6147
  logStatus(` Applied form layout for ${entity.Name}`);
5897
6148
  }
5898
6149
  }
6150
+ // Flush decision metadata for this entity
6151
+ await DecisionMetadataWriter.Instance.flushEntity(entity);
5899
6152
  }
5900
6153
  catch (ex) {
5901
6154
  logError('Error Processing Entity Advanced Generation', ex);
@@ -5965,19 +6218,21 @@ export class ManageMetadataBase {
5965
6218
  /**
5966
6219
  * Apply smart field identification results to entity fields and entity-level search configuration
5967
6220
  */
5968
- async applySmartFieldIdentification(pool, entity, fields, result) {
6221
+ async applySmartFieldIdentification(pool, entity, fields, result, ctx) {
5969
6222
  // Run the guardrail pipeline once up front so every downstream applier
5970
6223
  // sees the same already-normalized result. See search-guardrails.ts for
5971
6224
  // the rules. Mutates result in place — see normalizeSearchFlagsInPlace.
5972
6225
  this.normalizeSearchFlagsInPlace(entity, fields, result);
5973
6226
  const sqlStatements = [];
5974
- this.applyNameFieldUpdates(sqlStatements, fields, result);
5975
- this.applyDefaultInViewUpdates(sqlStatements, fields, result);
5976
- this.applySearchableFieldUpdates(sqlStatements, fields, result, entity);
5977
- this.applySearchPredicateUpdates(sqlStatements, fields, result);
5978
- 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
+ }
5979
6234
  if (configInfo.advancedGeneration?.allowFullTextSearchAutoUpdate) {
5980
- this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result);
6235
+ this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result, ctx);
5981
6236
  }
5982
6237
  // Execute all updates in one batch
5983
6238
  if (sqlStatements.length > 0) {
@@ -6116,11 +6371,11 @@ export class ManageMetadataBase {
6116
6371
  * `AutoUpdateIsNameField=0` are never touched in either direction — the user owns
6117
6372
  * them, and `EntityInfo.NameField`'s literal-`Name` preference arbitrates at runtime.
6118
6373
  */
6119
- applyNameFieldUpdates(sqlStatements, fields, result) {
6374
+ applyNameFieldUpdates(sqlStatements, fields, result, entity) {
6120
6375
  const nameFieldNames = result.nameFields ?? [];
6121
6376
  // Surface LLM candidates that don't exist on the entity (same diagnostics as before).
6122
6377
  for (const nfName of nameFieldNames) {
6123
- if (!fields.some(f => f.Name === nfName)) {
6378
+ if (!this.findFieldByName(fields, nfName)) {
6124
6379
  logError(`Smart field identification returned invalid nameField: '${nfName}' not found in entity fields`);
6125
6380
  }
6126
6381
  }
@@ -6133,6 +6388,9 @@ export class ManageMetadataBase {
6133
6388
  WHERE ID = '${winner.ID}'
6134
6389
  AND AutoUpdateIsNameField = ${this.boolLit(true)}
6135
6390
  `);
6391
+ if (entity?.Name) {
6392
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(winner.Name), 'IsNameField', true);
6393
+ }
6136
6394
  }
6137
6395
  // Clear every other flagged field that allows auto-update — single winner, always.
6138
6396
  for (const f of fields) {
@@ -6145,6 +6403,9 @@ export class ManageMetadataBase {
6145
6403
  WHERE ID = '${f.ID}'
6146
6404
  AND AutoUpdateIsNameField = ${this.boolLit(true)}
6147
6405
  `);
6406
+ if (entity?.Name) {
6407
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(f.Name), 'IsNameField', false);
6408
+ }
6148
6409
  }
6149
6410
  }
6150
6411
  /**
@@ -6166,7 +6427,7 @@ export class ManageMetadataBase {
6166
6427
  return this.preferLiteralNameField(eligibleFlagged);
6167
6428
  }
6168
6429
  for (const candidateName of rankedCandidates) {
6169
- const candidate = fields.find(f => f.Name === candidateName);
6430
+ const candidate = this.findFieldByName(fields, candidateName);
6170
6431
  if (candidate && this.isFieldEligibleForNameField(candidate)) {
6171
6432
  return candidate;
6172
6433
  }
@@ -6274,12 +6535,15 @@ export class ManageMetadataBase {
6274
6535
  /**
6275
6536
  * Generate SQL UPDATEs for DefaultInView on the identified default view fields
6276
6537
  */
6277
- 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));
6278
6542
  // Guard against a malformed LLM result that omits the array entirely.
6279
6543
  // Mirrors the defensive handling in the sibling apply* methods.
6280
6544
  const defaultInView = result.defaultInView ?? [];
6281
- const defaultInViewFields = fields.filter(f => defaultInView.includes(f.Name) && f.AutoUpdateDefaultInView && f.ID);
6282
- 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));
6283
6547
  if (missingFields.length > 0) {
6284
6548
  logError(`Smart field identification returned invalid defaultInView fields: ${missingFields.join(', ')} not found in entity`);
6285
6549
  }
@@ -6291,6 +6555,9 @@ export class ManageMetadataBase {
6291
6555
  WHERE ID = '${field.ID}'
6292
6556
  AND AutoUpdateDefaultInView = ${this.boolLit(true)}
6293
6557
  `);
6558
+ if (entity?.Name) {
6559
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'DefaultInView', true);
6560
+ }
6294
6561
  }
6295
6562
  }
6296
6563
  }
@@ -6305,12 +6572,13 @@ export class ManageMetadataBase {
6305
6572
  * always-wrong cases — they cause the data provider to emit unindexed scans on
6306
6573
  * every search.
6307
6574
  */
6308
- 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));
6309
6577
  if (!result.searchableFields || result.searchableFields.length === 0) {
6310
6578
  return;
6311
6579
  }
6312
- const searchableFields = fields.filter(f => result.searchableFields.includes(f.Name) && f.AutoUpdateIncludeInUserSearchAPI && f.ID);
6313
- 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));
6314
6582
  if (missingSearchableFields.length > 0) {
6315
6583
  logError(`Smart field identification returned invalid searchableFields: ${missingSearchableFields.join(', ')} not found in entity`);
6316
6584
  }
@@ -6328,6 +6596,9 @@ export class ManageMetadataBase {
6328
6596
  WHERE ID = '${field.ID}'
6329
6597
  AND AutoUpdateIncludeInUserSearchAPI = ${this.boolLit(true)}
6330
6598
  `);
6599
+ if (entity?.Name) {
6600
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'IncludeInUserSearchAPI', true);
6601
+ }
6331
6602
  }
6332
6603
  }
6333
6604
  }
@@ -6345,7 +6616,7 @@ export class ManageMetadataBase {
6345
6616
  if (!textTypes.has(type))
6346
6617
  return false;
6347
6618
  // Unbounded text on a non-FTX entity cannot be index-seeked.
6348
- const length = field.Length;
6619
+ const length = (field.Length ?? field.MaxLength);
6349
6620
  if (length === -1 && !ftxEnabled)
6350
6621
  return false;
6351
6622
  return true;
@@ -6353,17 +6624,20 @@ export class ManageMetadataBase {
6353
6624
  /**
6354
6625
  * Generate SQL UPDATEs for UserSearchPredicateAPI on each searchable field
6355
6626
  */
6356
- 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));
6357
6631
  if (!result.searchPredicates || result.searchPredicates.length === 0) {
6358
6632
  return;
6359
6633
  }
6360
6634
  for (const sp of result.searchPredicates) {
6361
- const field = fields.find(f => f.Name === sp.field);
6635
+ const field = this.findFieldByName(fields, sp.field);
6362
6636
  if (!field) {
6363
6637
  logError(`Smart field identification returned invalid searchPredicate field: '${sp.field}' not found in entity fields`);
6364
6638
  continue;
6365
6639
  }
6366
- if (!field.AutoUpdateUserSearchPredicate || !field.ID) {
6640
+ if (!field.AutoUpdateUserSearchPredicate || !field.ID || !inScope(field)) {
6367
6641
  continue;
6368
6642
  }
6369
6643
  // Only update if the current value differs from the recommended predicate
@@ -6374,6 +6648,9 @@ export class ManageMetadataBase {
6374
6648
  WHERE ID = '${field.ID}'
6375
6649
  AND AutoUpdateUserSearchPredicate = ${this.boolLit(true)}
6376
6650
  `);
6651
+ if (entity?.Name) {
6652
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'UserSearchPredicateAPI', sp.predicate);
6653
+ }
6377
6654
  }
6378
6655
  }
6379
6656
  }
@@ -6421,6 +6698,9 @@ export class ManageMetadataBase {
6421
6698
  WHERE ID = '${entity.ID}'
6422
6699
  AND AutoUpdateAllowUserSearchAPI = ${this.boolLit(true)}
6423
6700
  `);
6701
+ if (entity.Name) {
6702
+ DecisionMetadataWriter.Instance.recordEntityDecision(entity.Name, 'AllowUserSearchAPI', newValue);
6703
+ }
6424
6704
  }
6425
6705
  }
6426
6706
  /**
@@ -6451,9 +6731,10 @@ export class ManageMetadataBase {
6451
6731
  /**
6452
6732
  * Generate SQL UPDATEs for entity-level and field-level FullTextSearch configuration
6453
6733
  */
6454
- applyFullTextSearchUpdates(sqlStatements, entity, fields, result) {
6455
- // Entity-level FullTextSearchEnabled
6456
- 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) {
6457
6738
  const newValue = result.enableFullTextSearch;
6458
6739
  const currentValue = !!entity.FullTextSearchEnabled;
6459
6740
  if (newValue !== currentValue) {
@@ -6463,6 +6744,9 @@ export class ManageMetadataBase {
6463
6744
  WHERE ID = '${entity.ID}'
6464
6745
  AND AutoUpdateFullTextSearch = ${this.boolLit(true)}
6465
6746
  `);
6747
+ if (entity.Name) {
6748
+ DecisionMetadataWriter.Instance.recordEntityDecision(entity.Name, 'FullTextSearchEnabled', newValue);
6749
+ }
6466
6750
  }
6467
6751
  }
6468
6752
  // Field-level FullTextSearchEnabled
@@ -6470,12 +6754,12 @@ export class ManageMetadataBase {
6470
6754
  return;
6471
6755
  }
6472
6756
  for (const ftsFieldName of result.fullTextSearchFields) {
6473
- const field = fields.find(f => f.Name === ftsFieldName);
6757
+ const field = this.findFieldByName(fields, ftsFieldName);
6474
6758
  if (!field) {
6475
6759
  logError(`Smart field identification returned invalid fullTextSearchField: '${ftsFieldName}' not found in entity fields`);
6476
6760
  continue;
6477
6761
  }
6478
- if (!field.AutoUpdateFullTextSearch || !field.ID) {
6762
+ if (!field.AutoUpdateFullTextSearch || !field.ID || !inScope(field)) {
6479
6763
  continue;
6480
6764
  }
6481
6765
  if (!field.FullTextSearchEnabled) {
@@ -6485,6 +6769,9 @@ export class ManageMetadataBase {
6485
6769
  WHERE ID = '${field.ID}'
6486
6770
  AND AutoUpdateFullTextSearch = ${this.boolLit(true)}
6487
6771
  `);
6772
+ if (entity.Name) {
6773
+ DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'FullTextSearchEnabled', true);
6774
+ }
6488
6775
  }
6489
6776
  }
6490
6777
  }
@@ -6502,29 +6789,29 @@ export class ManageMetadataBase {
6502
6789
  // Defensive: a malformed LLM result can leave fieldCategories undefined or
6503
6790
  // non-array; both consumers below iterate it, so coerce to a safe array.
6504
6791
  const fieldCategories = Array.isArray(result.fieldCategories) ? result.fieldCategories : [];
6505
- await this.applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories);
6792
+ await this.applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories, { isNewEntity });
6506
6793
  // Auto-detect geo-capable entities and set SupportsGeoCoding
6507
- await this.detectAndSetGeoCodingSupport(pool, entity, fieldCategories);
6794
+ await this.detectAndSetGeoCodingSupport(pool, entity);
6508
6795
  if (result.entityIcon) {
6509
- await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
6796
+ await this.applyEntityIcon(pool, entity.ID, result.entityIcon, entity.Name);
6510
6797
  }
6511
6798
  // Resolve categoryInfo from new or legacy format
6512
6799
  const categoryInfoToStore = result.categoryInfo ||
6513
6800
  (result.categoryIcons ?
6514
6801
  Object.fromEntries(Object.entries(result.categoryIcons).map(([cat, icon]) => [cat, { icon, description: '' }])) : null);
6515
6802
  if (categoryInfoToStore) {
6516
- await this.applyCategoryInfoSettings(pool, entity.ID, categoryInfoToStore);
6803
+ await this.applyCategoryInfoSettings(pool, entity.ID, categoryInfoToStore, entity.Name);
6517
6804
  }
6518
6805
  if (isNewEntity && result.entityImportance) {
6519
- await this.applyEntityImportance(pool, entity.ID, result.entityImportance);
6806
+ await this.applyEntityImportance(pool, entity.ID, result.entityImportance, entity.Name);
6520
6807
  }
6521
6808
  }
6522
6809
  /**
6523
- * 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.
6524
6811
  * If any field has a Geo* ExtendedType, set Entity.SupportsGeoCoding = 1.
6525
6812
  * Respects the AutoUpdateSupportsGeoCoding flag — if 0, the value is locked.
6526
6813
  */
6527
- async detectAndSetGeoCodingSupport(pool, entity, fieldCategories) {
6814
+ async detectAndSetGeoCodingSupport(pool, entity) {
6528
6815
  const schema = mj_core_schema();
6529
6816
  // Check if the entity's AutoUpdateSupportsGeoCoding flag allows us to modify it
6530
6817
  const autoUpdateResult = await pool.query(`
@@ -6536,14 +6823,8 @@ export class ManageMetadataBase {
6536
6823
  if (!row || !row.AutoUpdateSupportsGeoCoding) {
6537
6824
  return; // Flag is locked, don't modify
6538
6825
  }
6539
- // Detect geo-capable fields from the LLM results
6540
- const geoExtendedTypes = new Set([
6541
- 'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
6542
- 'GeoCity', 'GeoPostalCode', 'GeoAddress'
6543
- ]);
6544
- const hasGeoFields = fieldCategories.some(fc => fc.extendedType != null && geoExtendedTypes.has(fc.extendedType));
6545
- // Also check existing fields in the database for Geo* ExtendedTypes
6546
- // (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)
6547
6828
  const existingGeoResult = await pool.query(`
6548
6829
  SELECT COUNT(*) AS ${this.qi('GeoFieldCount')}
6549
6830
  FROM ${this.qs(schema, 'EntityField')}
@@ -6551,7 +6832,7 @@ export class ManageMetadataBase {
6551
6832
  AND ${this.qi('ExtendedType')} IN ('Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince', 'GeoCity', 'GeoPostalCode', 'GeoAddress')
6552
6833
  `);
6553
6834
  const existingGeoCount = existingGeoResult?.recordset?.[0]?.GeoFieldCount ?? 0;
6554
- const shouldSupportGeo = hasGeoFields || existingGeoCount > 0;
6835
+ const shouldSupportGeo = existingGeoCount > 0;
6555
6836
  const currentValue = row.SupportsGeoCoding ? true : false;
6556
6837
  if (shouldSupportGeo !== currentValue) {
6557
6838
  await this.LogSQLAndExecute(pool, `
@@ -6559,7 +6840,8 @@ export class ManageMetadataBase {
6559
6840
  SET ${this.qi('SupportsGeoCoding')} = ${this.boolLit(shouldSupportGeo)}
6560
6841
  WHERE ${this.qi('ID')} = '${entity.ID}' AND ${this.qi('AutoUpdateSupportsGeoCoding')} = ${this.boolLit(true)}
6561
6842
  `, `Set SupportsGeoCoding = ${shouldSupportGeo} for ${entity.Name}`);
6562
- 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);
6563
6845
  // Queue for late-phase view regeneration — the view was already generated
6564
6846
  // before this flag was set, so it needs to be regenerated with the geo JOIN
6565
6847
  ManageMetadataBase.AddEntityRequiringViewRegen(entity.Name, 'Geocoding');
@@ -6606,55 +6888,72 @@ export class ManageMetadataBase {
6606
6888
  * Enforces stability rules: fields with existing categories cannot move to NEW categories.
6607
6889
  * All SQL updates are batched into a single execution for performance.
6608
6890
  */
6609
- async applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories) {
6891
+ async applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories, ctx) {
6610
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
+ }
6611
6906
  for (const fieldCategory of fieldCategories) {
6612
- const field = fields.find(f => f.Name === fieldCategory.fieldName);
6613
- if (field && field.AutoUpdateCategory && field.ID) {
6614
- // Override category to "System Metadata" for __mj_ fields (system audit fields)
6615
- let category = fieldCategory.category;
6616
- if (field.Name.startsWith('__mj_')) {
6617
- category = 'System Metadata';
6618
- }
6619
- // ENFORCEMENT: Prevent category renaming
6620
- const fieldHasExistingCategory = field.Category != null && field.Category.trim() !== '';
6621
- const categoryIsNew = !existingCategories.has(category);
6622
- if (fieldHasExistingCategory && categoryIsNew) {
6623
- logStatus(` Rejected category change for field '${field.Name}': cannot move from existing category '${field.Category}' to new category '${category}'. Keeping original category.`);
6624
- category = field.Category;
6625
- }
6626
- const setClauses = [];
6627
- if (field.Category !== category) {
6628
- setClauses.push(`Category = '${category.replace(/'/g, "''")}'`);
6629
- }
6630
- if (field.GeneratedFormSection !== 'Category') {
6631
- setClauses.push(`GeneratedFormSection = 'Category'`);
6632
- }
6633
- if (fieldCategory.displayName && field.AutoUpdateDisplayName && field.DisplayName !== fieldCategory.displayName) {
6634
- setClauses.push(`DisplayName = '${fieldCategory.displayName.replace(/'/g, "''")}'`);
6635
- }
6636
- if (fieldCategory.extendedType !== undefined && field.ExtendedType !== fieldCategory.extendedType) {
6637
- const extendedType = fieldCategory.extendedType === null ? 'NULL' : `'${String(fieldCategory.extendedType).replace(/'/g, "''")}'`;
6638
- 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');
6639
6923
  }
6640
- if (fieldCategory.codeType !== undefined) {
6641
- const sanitized = this.sanitizeCodeType(fieldCategory.codeType, field.Name, entity.Name);
6642
- if (field.CodeType !== sanitized) {
6643
- const codeType = sanitized == null ? 'NULL' : `'${sanitized.replace(/'/g, "''")}'`;
6644
- setClauses.push(`CodeType = ${codeType}`);
6645
- }
6924
+ else if (skip.reason === 'invalid') {
6925
+ reporter.counter('ai.fieldsInvalidProposal');
6646
6926
  }
6647
- if (setClauses.length > 0) {
6648
- // only generate an UPDATE if we have 1+ set clause
6649
- 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')}
6650
6953
  SET
6651
6954
  ${setClauses.join(',\n ')}
6652
6955
  WHERE
6653
- ID = '${field.ID}' AND AutoUpdateCategory = ${this.boolLit(true)}`);
6654
- }
6655
- }
6656
- else if (!field) {
6657
- logError(`Form layout returned invalid fieldName: '${fieldCategory.fieldName}' not found in entity`);
6956
+ ID = '${field.ID}'`);
6658
6957
  }
6659
6958
  }
6660
6959
  if (sqlStatements.length > 0) {
@@ -6669,7 +6968,7 @@ WHERE
6669
6968
  /**
6670
6969
  * Sets the entity icon if the entity doesn't already have one.
6671
6970
  */
6672
- async applyEntityIcon(pool, entityId, entityIcon) {
6971
+ async applyEntityIcon(pool, entityId, entityIcon, entityName) {
6673
6972
  if (!entityIcon || entityIcon.trim().length === 0)
6674
6973
  return;
6675
6974
  const checkSQL = `SELECT Icon FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE ID = '${entityId}'`;
@@ -6686,6 +6985,9 @@ WHERE
6686
6985
  try {
6687
6986
  await this.LogSQLAndExecute(pool, updateSQL, `Set entity icon to ${entityIcon}`, false);
6688
6987
  logStatus(` Set entity icon: ${entityIcon}`);
6988
+ if (entityName) {
6989
+ DecisionMetadataWriter.Instance.recordEntityDecision(entityName, 'Icon', entityIcon);
6990
+ }
6689
6991
  }
6690
6992
  catch (ex) {
6691
6993
  logError('Error Applying Entity Icon', ex);
@@ -6696,32 +6998,49 @@ WHERE
6696
6998
  /**
6697
6999
  * Upserts FieldCategoryInfo (new format) and FieldCategoryIcons (legacy format) in EntitySetting.
6698
7000
  */
6699
- async applyCategoryInfoSettings(pool, entityId, categoryInfo) {
7001
+ async applyCategoryInfoSettings(pool, entityId, categoryInfo, entityName) {
6700
7002
  if (!categoryInfo || Object.keys(categoryInfo).length === 0)
6701
7003
  return;
6702
- 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';
6703
7010
  // Upsert FieldCategoryInfo (new format)
6704
- 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'`;
6705
7012
  const existingNew = await this.runQuery(pool, checkNewSQL);
6706
7013
  if (existingNew.recordset.length > 0) {
7014
+ let isMatch = false;
6707
7015
  try {
6708
- await this.LogSQLAndExecute(pool, `
6709
- UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
6710
- SET ${this.qi('Value')} = '${infoJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
6711
- WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
6712
- `, `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);
6713
7019
  }
6714
- catch (ex) {
6715
- 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
+ }
6716
7034
  }
6717
7035
  }
6718
7036
  else {
6719
- const newId = uuidv4();
7037
+ const newId = uuidv5(`${entityId}|FieldCategoryInfo`, ENTITY_SETTING_NAMESPACE);
6720
7038
  try {
6721
- await this.LogSQLAndExecute(pool, `
6722
- 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)})
6723
- VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})
6724
- `, `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);
6725
7044
  }
6726
7045
  catch (ex) {
6727
7046
  logError('Error Applying Category Info Settings: Part 2', ex);
@@ -6734,28 +7053,44 @@ WHERE
6734
7053
  iconsOnly[category] = info.icon;
6735
7054
  }
6736
7055
  }
6737
- const iconsJSON = JSON.stringify(iconsOnly).replace(/'/g, "''");
6738
- 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'`;
6739
7062
  const existingLegacy = await this.runQuery(pool, checkLegacySQL);
6740
7063
  if (existingLegacy.recordset.length > 0) {
7064
+ let isMatch = false;
6741
7065
  try {
6742
- await this.LogSQLAndExecute(pool, `
6743
- UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
6744
- SET ${this.qi('Value')} = '${iconsJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
6745
- WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
6746
- `, `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);
6747
7069
  }
6748
- catch (ex) {
6749
- 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
+ }
6750
7084
  }
6751
7085
  }
6752
7086
  else {
6753
- const newId = uuidv4();
7087
+ const newId = uuidv5(`${entityId}|FieldCategoryIcons`, ENTITY_SETTING_NAMESPACE);
6754
7088
  try {
6755
- await this.LogSQLAndExecute(pool, `
6756
- 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)})
6757
- VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})
6758
- `, `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);
6759
7094
  }
6760
7095
  catch (ex) {
6761
7096
  logError('Error Applying Category Info Settings: Part 4', ex);
@@ -6766,7 +7101,7 @@ WHERE
6766
7101
  * Applies entity importance analysis to MJApplicationEntity records.
6767
7102
  * Only called for NEW entities to set DefaultForNewUser.
6768
7103
  */
6769
- async applyEntityImportance(pool, entityId, importance) {
7104
+ async applyEntityImportance(pool, entityId, importance, entityName) {
6770
7105
  const updateSQL = `
6771
7106
  UPDATE ${this.qs(mj_core_schema(), 'ApplicationEntity')}
6772
7107
  SET ${this.qi('DefaultForNewUser')} = ${this.boolLit(importance.defaultForNewUser)}, ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
@@ -6776,6 +7111,9 @@ WHERE
6776
7111
  await this.LogSQLAndExecute(pool, updateSQL, `Set DefaultForNewUser=${importance.defaultForNewUser} for NEW entity (category: ${importance.entityCategory}, confidence: ${importance.confidence})`, false);
6777
7112
  logStatus(` Entity importance (NEW Entity): ${importance.entityCategory} (defaultForNewUser: ${importance.defaultForNewUser}, confidence: ${importance.confidence})`);
6778
7113
  logStatus(` Reasoning: ${importance.reasoning}`);
7114
+ if (entityName) {
7115
+ DecisionMetadataWriter.Instance.recordApplicationEntityDecision(entityName, '', 'DefaultForNewUser', importance.defaultForNewUser);
7116
+ }
6779
7117
  }
6780
7118
  catch (ex) {
6781
7119
  logError('Error Applying Entity Importance', ex);