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

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 (103) 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 +29 -2
  17. package/dist/Config/config.d.ts.map +1 -1
  18. package/dist/Config/config.js +54 -5
  19. package/dist/Config/config.js.map +1 -1
  20. package/dist/Database/codeGenDatabaseProvider.d.ts +64 -1
  21. package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
  22. package/dist/Database/codeGenDatabaseProvider.js +49 -3
  23. package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
  24. package/dist/Database/dbSchema.js +2 -1
  25. package/dist/Database/dbSchema.js.map +1 -1
  26. package/dist/Database/entity-field-change-tracking.d.ts +53 -0
  27. package/dist/Database/entity-field-change-tracking.d.ts.map +1 -0
  28. package/dist/Database/entity-field-change-tracking.js +120 -0
  29. package/dist/Database/entity-field-change-tracking.js.map +1 -0
  30. package/dist/Database/entity-level-tree-cycles.d.ts.map +1 -1
  31. package/dist/Database/entity-level-tree-cycles.js +3 -2
  32. package/dist/Database/entity-level-tree-cycles.js.map +1 -1
  33. package/dist/Database/field-metadata-lock.d.ts +47 -0
  34. package/dist/Database/field-metadata-lock.d.ts.map +1 -0
  35. package/dist/Database/field-metadata-lock.js +159 -0
  36. package/dist/Database/field-metadata-lock.js.map +1 -0
  37. package/dist/Database/manage-metadata.d.ts +133 -51
  38. package/dist/Database/manage-metadata.d.ts.map +1 -1
  39. package/dist/Database/manage-metadata.js +574 -306
  40. package/dist/Database/manage-metadata.js.map +1 -1
  41. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +10 -2
  42. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
  43. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +113 -42
  44. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
  45. package/dist/Database/providers/postgresql/metadataSupportObjects.js +31 -8
  46. package/dist/Database/providers/postgresql/metadataSupportObjects.js.map +1 -1
  47. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +47 -2
  48. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
  49. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +176 -30
  50. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
  51. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js +15 -2
  52. package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js.map +1 -1
  53. package/dist/Database/reconcileFieldLevelSecurity.d.ts +22 -0
  54. package/dist/Database/reconcileFieldLevelSecurity.d.ts.map +1 -0
  55. package/dist/Database/reconcileFieldLevelSecurity.js +47 -0
  56. package/dist/Database/reconcileFieldLevelSecurity.js.map +1 -0
  57. package/dist/Database/sql_codegen.d.ts +34 -0
  58. package/dist/Database/sql_codegen.d.ts.map +1 -1
  59. package/dist/Database/sql_codegen.js +152 -14
  60. package/dist/Database/sql_codegen.js.map +1 -1
  61. package/dist/Manifest/GenerateClassRegistrationsManifest.d.ts.map +1 -1
  62. package/dist/Manifest/GenerateClassRegistrationsManifest.js +3 -2
  63. package/dist/Manifest/GenerateClassRegistrationsManifest.js.map +1 -1
  64. package/dist/Misc/action_subclasses_codegen.js +2 -2
  65. package/dist/Misc/action_subclasses_codegen.js.map +1 -1
  66. package/dist/Misc/advanced_generation.d.ts +17 -5
  67. package/dist/Misc/advanced_generation.d.ts.map +1 -1
  68. package/dist/Misc/advanced_generation.js +69 -23
  69. package/dist/Misc/advanced_generation.js.map +1 -1
  70. package/dist/Misc/codegen-reporter.d.ts +2 -0
  71. package/dist/Misc/codegen-reporter.d.ts.map +1 -1
  72. package/dist/Misc/codegen-reporter.js +6 -2
  73. package/dist/Misc/codegen-reporter.js.map +1 -1
  74. package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
  75. package/dist/Misc/entity_subclasses_codegen.js +30 -20
  76. package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
  77. package/dist/Misc/graphql_server_codegen.d.ts +39 -0
  78. package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
  79. package/dist/Misc/graphql_server_codegen.js +63 -9
  80. package/dist/Misc/graphql_server_codegen.js.map +1 -1
  81. package/dist/Misc/remote_operations_codegen.d.ts +36 -1
  82. package/dist/Misc/remote_operations_codegen.d.ts.map +1 -1
  83. package/dist/Misc/remote_operations_codegen.js +178 -8
  84. package/dist/Misc/remote_operations_codegen.js.map +1 -1
  85. package/dist/Misc/schema-emit.d.ts.map +1 -1
  86. package/dist/Misc/schema-emit.js +2 -1
  87. package/dist/Misc/schema-emit.js.map +1 -1
  88. package/dist/Misc/sql_logging.d.ts +63 -8
  89. package/dist/Misc/sql_logging.d.ts.map +1 -1
  90. package/dist/Misc/sql_logging.js +169 -42
  91. package/dist/Misc/sql_logging.js.map +1 -1
  92. package/dist/Misc/util.d.ts +12 -0
  93. package/dist/Misc/util.d.ts.map +1 -1
  94. package/dist/Misc/util.js +71 -8
  95. package/dist/Misc/util.js.map +1 -1
  96. package/dist/plugins/index.d.ts +3 -0
  97. package/dist/plugins/index.d.ts.map +1 -1
  98. package/dist/plugins/index.js +28 -1
  99. package/dist/plugins/index.js.map +1 -1
  100. package/dist/runCodeGen.d.ts.map +1 -1
  101. package/dist/runCodeGen.js +134 -26
  102. package/dist/runCodeGen.js.map +1 -1
  103. package/package.json +29 -29
@@ -11,22 +11,26 @@ 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";
18
18
  import { applyIncludeSchemaScope } from "./schema-scope.js";
19
19
  import { buildHealSchemaRoutineParams, getAuthoredExcludeSchemas, snapshotAuthoredExcludeSchemas } from "./heal-schema-params.js";
20
- import { AdvancedGeneration } from "../Misc/advanced_generation.js";
20
+ import { AdvancedGeneration, isPlausibleEntityName } from "../Misc/advanced_generation.js";
21
21
  import { CodeGenReporter } from "../Misc/codegen-reporter.js";
22
22
  import { applySearchableFieldsCap, entityLevelEnableBlockedReason, isNarrativeFieldName, normalizePredicate, normalizeSmartFieldResultShape, } from "./search-guardrails.js";
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 { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
33
+ export { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
30
34
  export class ValidatorResult {
31
35
  constructor() {
32
36
  this.entityName = "";
@@ -141,6 +145,19 @@ export class ManageMetadataBase {
141
145
  boolLit(value) {
142
146
  return this.dialect.BooleanLiteral(value);
143
147
  }
148
+ /**
149
+ * Returns an explicitly-typed UUID literal for the platform.
150
+ * SQL Server: CAST('...' AS uniqueidentifier), PostgreSQL: CAST('...' AS uuid)
151
+ *
152
+ * Needed wherever a UUID literal appears in the SELECT list of an `INSERT ... SELECT`. A bare
153
+ * quoted literal is fine in `INSERT ... VALUES`, where both platforms coerce it to the target
154
+ * column's type — but in the projection of a SELECT, PostgreSQL can resolve an untyped literal
155
+ * to `text` and then refuse to assign it to a `uuid` column. `CAST(x AS y)` is ANSI and settles
156
+ * it on both, with the type name coming from the dialect rather than a platform branch here.
157
+ */
158
+ uuidLit(value) {
159
+ return `CAST('${value}' AS ${this.dialect.UuidTypeNames[0]})`;
160
+ }
144
161
  /**
145
162
  * Quotes mixed-case identifiers in a SQL string for the current platform.
146
163
  * Delegates to the database provider's quoteSQLForExecution method.
@@ -161,47 +178,19 @@ export class ManageMetadataBase {
161
178
  return pool.queryWithParams(this.qsql(sql), params);
162
179
  }
163
180
  /**
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.
181
+ * The `Sequence` expression for every EntityField INSERT CodeGen emits:
182
+ * `(SELECT COALESCE(MAX(Sequence), 0) + 1 FROM EntityField WHERE EntityID = '<id>')`.
167
183
  *
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
- }
184
+ * Evaluated at APPLY time, never a literal. The value is disposable — the run's own
185
+ * spUpdateExistingEntityFieldsFromSchema pass and R__RefreshMetadata renumber every field from
186
+ * the schema — but it must be unique on ANY database in ANY order, because the INSERT is
187
+ * appended verbatim to a migration and Flyway runs every versioned migration before the
188
+ * repeatable renumber. Each INSERT re-evaluates MAX after the one before it, so a batch that
189
+ * executes sequentially in emission order rises in that order (#3670, #4202).
190
+ */
191
+ applyTimeEntityFieldSequenceSQL(entityID) {
192
+ // COALESCE is valid on both platforms and is the form the docs and the gate describe.
193
+ return `(SELECT COALESCE(MAX(${this.qi('Sequence')}), 0) + 1 FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ${this.qi('EntityID')} = '${entityID}')`;
205
194
  }
206
195
  /**
207
196
  * Wraps a SELECT query with a row limit.
@@ -276,6 +265,9 @@ export class ManageMetadataBase {
276
265
  static get newEntityList() {
277
266
  return this._newEntityList;
278
267
  }
268
+ static set newEntityList(value) {
269
+ this._newEntityList = value;
270
+ }
279
271
  static { this._modifiedEntityList = []; }
280
272
  /**
281
273
  * Globally scoped list of entities that have been modified during the metadata management process.
@@ -283,6 +275,108 @@ export class ManageMetadataBase {
283
275
  static get modifiedEntityList() {
284
276
  return this._modifiedEntityList;
285
277
  }
278
+ static set modifiedEntityList(value) {
279
+ this._modifiedEntityList = value;
280
+ }
281
+ static { this._newFieldSet = new Set(); }
282
+ static { this._changedFields = new Map(); }
283
+ static { this._changedFieldReport = []; }
284
+ static fieldKey(entityID, name) {
285
+ const eid = (entityID ?? '').trim().toLowerCase();
286
+ const fld = (name ?? '').trim().toLowerCase();
287
+ if (!eid || !fld) {
288
+ return null;
289
+ }
290
+ return `${eid}:${fld}`;
291
+ }
292
+ static registerNewField(entityID, name) {
293
+ const key = this.fieldKey(entityID, name);
294
+ if (key) {
295
+ this._newFieldSet.add(key);
296
+ }
297
+ }
298
+ static registerFieldChange(entityID, name, reasons) {
299
+ const key = this.fieldKey(entityID, name);
300
+ if (key) {
301
+ let set = this._changedFields.get(key);
302
+ if (!set) {
303
+ set = new Set();
304
+ this._changedFields.set(key, set);
305
+ }
306
+ for (const r of reasons) {
307
+ set.add(r);
308
+ }
309
+ }
310
+ }
311
+ static isFieldNew(entityID, name) {
312
+ const key = this.fieldKey(entityID, name);
313
+ return key ? this._newFieldSet.has(key) : false;
314
+ }
315
+ static fieldChangeReasons(entityID, name) {
316
+ const key = this.fieldKey(entityID, name);
317
+ const set = key ? this._changedFields.get(key) : undefined;
318
+ return set ?? new Set();
319
+ }
320
+ static isDisplayNameReopened(entityID, name) {
321
+ const reasons = this.fieldChangeReasons(entityID, name);
322
+ for (const r of reasons) {
323
+ if (DISPLAYNAME_REOPEN_REASONS.has(r)) {
324
+ return true;
325
+ }
326
+ }
327
+ return false;
328
+ }
329
+ static isTypeReopened(entityID, name) {
330
+ const reasons = this.fieldChangeReasons(entityID, name);
331
+ for (const r of reasons) {
332
+ if (TYPE_REOPEN_REASONS.has(r)) {
333
+ return true;
334
+ }
335
+ }
336
+ return false;
337
+ }
338
+ static get newFieldCount() {
339
+ return this._newFieldSet.size;
340
+ }
341
+ static get changedFieldCount() {
342
+ return this._changedFields.size;
343
+ }
344
+ static get changedFieldReport() {
345
+ return this._changedFieldReport;
346
+ }
347
+ static clearFieldTracking() {
348
+ this._newFieldSet.clear();
349
+ this._changedFields.clear();
350
+ this._changedFieldReport = [];
351
+ }
352
+ /**
353
+ * Takes a snapshot of EntityField rows from vwEntityFields for diffing.
354
+ * Only queries columns in TRACKED_FIELD_COLUMNS (Sequence is excluded).
355
+ */
356
+ async snapshotEntityFields(pool, entityIDs) {
357
+ const isScoped = entityIDs !== undefined && entityIDs.length > 0;
358
+ const filter = isScoped
359
+ ? `WHERE ${this.qi('EntityID')} IN (${entityIDs.map(id => `'${id}'`).join(',')})`
360
+ : '';
361
+ const cols = TRACKED_FIELD_COLUMNS.map(c => this.qi(c)).join(', ');
362
+ const sSQL = `SELECT ${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Entity')} AS ${this.qi('EntityName')}, ${this.qi('Name')}, ${cols}
363
+ FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
364
+ ${filter}`;
365
+ const result = await this.runQuery(pool, sSQL);
366
+ const map = new Map();
367
+ if (result && result.recordset) {
368
+ for (const r of result.recordset) {
369
+ map.set(String(r.ID).toLowerCase(), r);
370
+ }
371
+ }
372
+ return map;
373
+ }
374
+ findFieldByName(fields, name) {
375
+ if (!name)
376
+ return undefined;
377
+ const target = name.trim().toLowerCase();
378
+ return fields.find(f => (f.Name ?? '').trim().toLowerCase() === target);
379
+ }
286
380
  static { this._deletedEntitySchemaList = []; }
287
381
  /**
288
382
  * Schemas that lost an entity during this run.
@@ -939,29 +1033,63 @@ export class ManageMetadataBase {
939
1033
  logStatus(` > Entities config: "${ec.SchemaName}.${ec.BaseTable}" has no attributes to set — skipping`);
940
1034
  continue;
941
1035
  }
942
- // Look up the entity by BaseTable + SchemaName
1036
+ // Look up the entity by BaseTable + SchemaName, selecting current values for the named attributes
1037
+ const attrCols = attrs.map(([k]) => `${this.qi(k)}`).join(', ');
943
1038
  const entityResult = await this.runQueryWithParams(pool, `
944
- ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(schema, 'vwEntities')}
1039
+ ${this.selectTop(1, `ID, Name, ${attrCols}`, `FROM ${this.qs(schema, 'vwEntities')}
945
1040
  WHERE BaseTable = @BaseTable AND SchemaName = @SchemaName`)}
946
1041
  `, { 'BaseTable': ec.BaseTable, 'SchemaName': ec.SchemaName });
947
1042
  if (entityResult.recordset.length === 0) {
948
1043
  logError(` > Entities config: entity for "${ec.SchemaName}.${ec.BaseTable}" not found — skipping`);
949
1044
  continue;
950
1045
  }
951
- const entityId = entityResult.recordset[0].ID;
952
- const entityName = entityResult.recordset[0].Name;
953
- // Build a parameterized UPDATE with one SET clause per attribute
954
- const queryParams = { 'EntityID': entityId };
955
- const setClauses = [];
1046
+ const currentRow = entityResult.recordset[0];
1047
+ const entityId = String(currentRow.ID);
1048
+ const entityName = String(currentRow.Name);
1049
+ // Compare each attribute against the current database value; only include differing attributes
1050
+ const differingAttrs = [];
956
1051
  for (const [key, value] of attrs) {
957
- const paramName = `attr_${key}`;
958
- // Pass booleans through — both mssql and pg drivers handle native boolean values
959
- const sqlValue = value;
960
- queryParams[paramName] = sqlValue;
961
- setClauses.push(`${this.qi(key)} = @${paramName}`);
1052
+ const curVal = currentRow[key];
1053
+ let differs = false;
1054
+ let litVal = 'NULL';
1055
+ if (typeof value === 'boolean') {
1056
+ const curBool = curVal === true || curVal === 1 || curVal === '1';
1057
+ differs = curBool !== value;
1058
+ litVal = this.boolLit(value);
1059
+ }
1060
+ else if (typeof value === 'number') {
1061
+ const curNum = curVal === null || curVal === undefined ? null : Number(curVal);
1062
+ differs = curNum !== value;
1063
+ litVal = String(value);
1064
+ }
1065
+ else if (typeof value === 'string') {
1066
+ const curStr = curVal === null || curVal === undefined ? '' : String(curVal);
1067
+ differs = curStr !== value;
1068
+ litVal = `'${value.replace(/'/g, "''")}'`;
1069
+ }
1070
+ else if (value === null) {
1071
+ differs = curVal !== null && curVal !== undefined;
1072
+ litVal = 'NULL';
1073
+ }
1074
+ else {
1075
+ differs = JSON.stringify(curVal) !== JSON.stringify(value);
1076
+ litVal = `'${JSON.stringify(value).replace(/'/g, "''")}'`;
1077
+ }
1078
+ if (differs) {
1079
+ differingAttrs.push({ key, literalValue: litVal, summary: `${key}=${value}` });
1080
+ }
962
1081
  }
963
- await this.runQueryWithParams(pool, `UPDATE ${this.qs(schema, 'Entity')} SET ${setClauses.join(', ')} WHERE ID = @EntityID`, queryParams);
964
- const attrSummary = attrs.map(([k, v]) => `${k}=${v}`).join(', ');
1082
+ if (differingAttrs.length === 0) {
1083
+ continue;
1084
+ }
1085
+ // Build an UPDATE with literal values and execute through LogSQLAndExecute for migration capture
1086
+ const setClauses = differingAttrs.map(a => `${this.qi(a.key)} = ${a.literalValue}`);
1087
+ setClauses.unshift(`${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}`);
1088
+ const updateSQL = `UPDATE ${this.qs(schema, 'Entity')}
1089
+ SET ${setClauses.join(', ')}
1090
+ WHERE ${this.qi('ID')} = '${entityId}'`;
1091
+ await this.LogSQLAndExecute(pool, updateSQL, `Update attributes on "${entityName}" from additionalSchemaInfo`);
1092
+ const attrSummary = differingAttrs.map(a => a.summary).join(', ');
965
1093
  logStatus(` > Entities config: Set ${attrSummary} on "${entityName}"`);
966
1094
  updatedCount++;
967
1095
  }
@@ -1123,9 +1251,10 @@ export class ManageMetadataBase {
1123
1251
  const tableSQL = this.dbProvider.generateMaterializedTableSQL(matSchema, tableName, columns);
1124
1252
  const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(matSchema, viewName, tableName);
1125
1253
  // includeBatchSeparator: each is a single GO-free batch (executed via ds.query), but the
1126
- // migration file needs a GO between statements for Flyway/sqlcmd.
1127
- await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for base-view materialization of entity ${entity.Name}`, false, true);
1128
- await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for base-view materialization of entity ${entity.Name}`, false, true);
1254
+ // migration file needs a GO between statements for Flyway/sqlcmd. Pass the provider's
1255
+ // separator, not the 'GO' default: on PostgreSQL it is '' and a literal GO breaks replay.
1256
+ await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for base-view materialization of entity ${entity.Name}`, false, true, this.dbProvider.BatchSeparator);
1257
+ await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for base-view materialization of entity ${entity.Name}`, false, true, this.dbProvider.BatchSeparator);
1129
1258
  // 4) Upsert the MJ: Materialized Results row, keyed on (SourceType, SourceEntityID).
1130
1259
  const existing = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'MaterializedResult')} WHERE SourceType = @SourceType AND SourceEntityID = @EntityID`, { SourceType: 'EntityBaseView', EntityID: entity.ID });
1131
1260
  if (existing.recordset.length > 0) {
@@ -1383,8 +1512,8 @@ export class ManageMetadataBase {
1383
1512
  }
1384
1513
  const tableSQL = this.dbProvider.generateMaterializedTableSQL(coreSchema, tableName, analysis.columns);
1385
1514
  const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(coreSchema, viewName, tableName);
1386
- await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true);
1387
- await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true);
1515
+ await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true, this.dbProvider.BatchSeparator);
1516
+ await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true, this.dbProvider.BatchSeparator);
1388
1517
  // 3) Mint the read-only Virtual Entity over the wrapper view (idempotent by view).
1389
1518
  const existingVE = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'vwEntities')} WHERE BaseView = @V AND SchemaName = @S`, { V: viewName, S: coreSchema });
1390
1519
  let generatedEntityId = existingVE.recordset[0]?.ID;
@@ -1964,8 +2093,8 @@ export class ManageMetadataBase {
1964
2093
  * INTEGRITY CHECK — in a well-formed entity every base (non-virtual) field sequences BEFORE the
1965
2094
  * virtual/related fields, so the EntityField order matches the base view's `SELECT [base].*, <joins>`
1966
2095
  * column output. The positional save-capture in the data providers (e.g. SQLServerDataProvider's
1967
- * @ResultTable) relies on that alignment. CodeGen assigns newly-discovered columns a temporary
1968
- * `maxSequence + 100000` offset that updateExistingEntityFieldsFromSchema is supposed to renumber; if
2096
+ * @ResultTable) relies on that alignment. CodeGen inserts newly-discovered columns at an apply-time
2097
+ * `MAX(Sequence) + ordinal` placeholder that updateExistingEntityFieldsFromSchema is supposed to renumber; if
1969
2098
  * a base column is left sequenced AFTER a virtual field, the save-capture would mis-route values by
1970
2099
  * position. The providers now compensate (saves stay correct), but the metadata is still wrong — so we
1971
2100
  * scan for it after every metadata pass and log a prominent warning to drive the root cause out over time.
@@ -1978,7 +2107,7 @@ export class ManageMetadataBase {
1978
2107
  const sSQL = `SELECT ${this.qi('EntityID')}, ${this.qi('Entity')}, ${this.qi('Name')}, ${this.qi('Sequence')}, ${this.qi('IsVirtual')}
1979
2108
  FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
1980
2109
  ${excl}
1981
- ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}`;
2110
+ ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}, ${this.qi('Name')}`;
1982
2111
  const result = await this.runQuery(pool, sSQL);
1983
2112
  const rows = result.recordset;
1984
2113
  const isV = (v) => v === true || v === 1 || v === '1';
@@ -2011,7 +2140,7 @@ export class ManageMetadataBase {
2011
2140
  ` Healthy entities sequence ALL base (non-virtual) fields before virtual/related fields, so EntityField\n` +
2012
2141
  ` order matches the base view ([base].* then joins) that the data-provider save-capture relies on. When a\n` +
2013
2142
  ` base column sequences after a virtual field, the capture re-orders defensively (saves stay correct), but\n` +
2014
- ` the metadata is wrong. Usual cause: a newly-added column's temporary maxSequence+100000 offset was not\n` +
2143
+ ` the metadata is wrong. Usual cause: a newly-added column's apply-time MAX(Sequence)+ordinal placeholder was not\n` +
2015
2144
  ` renumbered. Fix the field's Sequence at the source so it sorts with the base columns.\n` +
2016
2145
  ` Affected (entity: base fields out of order):\n`;
2017
2146
  for (const o of offenders)
@@ -2464,11 +2593,8 @@ export class ManageMetadataBase {
2464
2593
  else {
2465
2594
  // this means that we do NOT have a match so the field does not exist in the entity definition, so we need to add it
2466
2595
  newEntityFieldUUID = this.createNewUUID();
2467
- // Compute a non-colliding sequence by querying MAX(Sequence) for this entity. The
2468
- // ordinal `fieldSequence` (column index in the view) is correct for stable ordering
2469
- // when nothing exists yet, but a partial prior run could have left rows at the same
2470
- // ordinal under this EntityID — UQ_EntityField_EntityID_Sequence then fires on PG.
2471
- const safeSequence = await this.nextAvailableEntityFieldSequence(pool, entity.ID, fieldSequence);
2596
+ // Apply-time Sequence: see applyTimeEntityFieldSequenceSQL. A literal here would be
2597
+ // replayed verbatim from the CodeGen capture and collide on a fresh install.
2472
2598
  const q = (n) => this.qi(n);
2473
2599
  const sqlAdd = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
2474
2600
  ${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
@@ -2477,10 +2603,11 @@ export class ManageMetadataBase {
2477
2603
  ${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
2478
2604
  VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${String(veField.FieldName).replace(/'/g, "''")}', '${veField.Type}', ${this.boolLit(veField.AllowsNull)},
2479
2605
  ${veField.Length}, ${veField.Precision}, ${veField.Scale},
2480
- ${safeSequence}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
2606
+ ${this.applyTimeEntityFieldSequenceSQL(entity.ID)}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
2481
2607
  ${this.utcNow()}, ${this.utcNow()}
2482
2608
  )`;
2483
2609
  await this.LogSQLAndExecute(pool, sqlAdd, `SQL text to add virtual entity field ${veField.FieldName} for entity ${virtualEntity.Name}`);
2610
+ ManageMetadataBase.registerNewField(entity.ID, veField.FieldName);
2484
2611
  didUpdate = true;
2485
2612
  }
2486
2613
  }
@@ -2502,14 +2629,16 @@ export class ManageMetadataBase {
2502
2629
  if (virtualEntities.length === 0) {
2503
2630
  return;
2504
2631
  }
2505
- // Pre-build available entities list once (shared across all virtual entity decorations)
2632
+ // Pre-build available entities list once (shared across all virtual entity decorations).
2633
+ // Only single-column keys are offered as FK targets: an MJ foreign key references exactly one
2634
+ // column (RelatedEntityFieldName), so a composite-key entity cannot be the target of a soft FK.
2506
2635
  const availableEntities = md.Entities
2507
- .filter(e => !e.VirtualEntity && e.PrimaryKeys.length > 0)
2636
+ .filter(e => !e.VirtualEntity && e.PrimaryKeys.length === 1)
2508
2637
  .map(e => ({
2509
2638
  Name: e.Name,
2510
2639
  SchemaName: e.SchemaName,
2511
2640
  BaseTable: e.BaseTable,
2512
- PrimaryKeyField: e.PrimaryKeys[0]?.Name || 'ID'
2641
+ PrimaryKeyField: e.FirstPrimaryKey.Name // first-pk-ok: filtered to PrimaryKeys.length === 1 above; this is the FK target column offered to the LLM
2513
2642
  }));
2514
2643
  logStatus(` Decorating virtual entity fields with LLM (${virtualEntities.length} entities)...`);
2515
2644
  let decoratedCount = 0;
@@ -2641,7 +2770,7 @@ export class ManageMetadataBase {
2641
2770
  // Load VE EntityField rows from DB (we need the ID and auto-update flags)
2642
2771
  const schema = mj_core_schema();
2643
2772
  const fieldsSQL = `
2644
- SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, GeneratedFormSection, DisplayName, ExtendedType, CodeType
2773
+ SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, AutoUpdateExtendedType, GeneratedFormSection, DisplayName, ExtendedType, CodeType, ValueListType
2645
2774
  FROM ${this.qs(schema, 'EntityField')}
2646
2775
  WHERE EntityID = '${entity.ID}'
2647
2776
  `;
@@ -2662,14 +2791,15 @@ export class ManageMetadataBase {
2662
2791
  if (fieldCategories.length === 0)
2663
2792
  return false;
2664
2793
  const existingCategories = this.buildExistingCategorySet(dbFields);
2665
- await this.applyFieldCategories(pool, entity, dbFields, fieldCategories, existingCategories);
2794
+ const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
2795
+ await this.applyFieldCategories(pool, entity, dbFields, fieldCategories, existingCategories, { isNewEntity });
2666
2796
  // Apply entity icon if provided
2667
2797
  if (result.entityIcon) {
2668
- await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
2798
+ await this.applyEntityIcon(pool, entity.ID, result.entityIcon, entity.Name);
2669
2799
  }
2670
2800
  // Apply category info settings if provided
2671
2801
  if (result.categoryInfo && Object.keys(result.categoryInfo).length > 0) {
2672
- await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo);
2802
+ await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo, entity.Name);
2673
2803
  }
2674
2804
  logStatus(` Applied categories for VE ${entity.Name} (${fieldCategories.length} fields)`);
2675
2805
  return true;
@@ -2769,9 +2899,12 @@ export class ManageMetadataBase {
2769
2899
  continue;
2770
2900
  }
2771
2901
  const escapedDescription = fd.description.replace(/'/g, "''");
2772
- let setClauses = `Description='${escapedDescription}'`;
2773
- // Apply extended type if provided and valid
2774
- if (fd.extendedType) {
2902
+ let setClauses = `Description='${escapedDescription}', ${this.qi('AutoUpdateDescription')} = ${this.boolLit(false)}`;
2903
+ // Apply extended type if provided, valid, and the field is not locked
2904
+ const isFieldNew = ManageMetadataBase.isFieldNew(entity.ID, field.Name);
2905
+ const isTypeReopened = ManageMetadataBase.isTypeReopened(entity.ID, field.Name);
2906
+ const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
2907
+ if (fd.extendedType && field.AutoUpdateExtendedType && (isNewEntity || isFieldNew || isTypeReopened)) {
2775
2908
  const validExtendedType = this.validateExtendedType(fd.extendedType);
2776
2909
  if (validExtendedType) {
2777
2910
  setClauses += `, ExtendedType='${validExtendedType}'`;
@@ -2788,12 +2921,9 @@ export class ManageMetadataBase {
2788
2921
  return true;
2789
2922
  }
2790
2923
  /**
2791
- * Valid values for EntityField.ExtendedType, plus common LLM aliases mapped to valid values.
2924
+ * Valid values for EntityField.ExtendedType. Domain lives on {@link EntityFieldInfo.ExtendedTypes}.
2792
2925
  */
2793
- static { this.VALID_EXTENDED_TYPES = new Set([
2794
- 'Code', 'Email', 'FaceTime', 'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
2795
- 'GeoCity', 'GeoPostalCode', 'GeoAddress', 'MSTeams', 'Other', 'SIP', 'SMS', 'Skype', 'Tel', 'URL', 'WhatsApp', 'ZoomMtg'
2796
- ]); }
2926
+ static { this.VALID_EXTENDED_TYPES = new Set(EntityFieldInfo.ExtendedTypes); }
2797
2927
  static { this.EXTENDED_TYPE_ALIASES = {
2798
2928
  'phone': 'Tel',
2799
2929
  'telephone': 'Tel',
@@ -2823,6 +2953,17 @@ export class ManageMetadataBase {
2823
2953
  'zoom': 'ZoomMtg',
2824
2954
  'whatsapp': 'WhatsApp',
2825
2955
  'skype': 'Skype',
2956
+ 'image': 'Image',
2957
+ 'photo': 'Image',
2958
+ 'picture': 'Image',
2959
+ 'logo': 'Image',
2960
+ 'avatar': 'Image',
2961
+ 'thumbnail': 'Image',
2962
+ 'color': 'Color',
2963
+ 'colour': 'Color',
2964
+ 'hex': 'Color',
2965
+ 'json': 'JSON',
2966
+ 'jsonb': 'JSON',
2826
2967
  }; }
2827
2968
  /**
2828
2969
  * Validates an LLM-suggested ExtendedType against the allowed values in EntityField.
@@ -2930,12 +3071,8 @@ export class ManageMetadataBase {
2930
3071
  else {
2931
3072
  // Create new virtual field record for this parent field
2932
3073
  const newFieldID = this.createNewUUID();
2933
- // Use high sequence — will be reordered by updateExistingEntityFieldsFromSchema.
2934
- // Query MAX(Sequence) at insert time so we never collide with an existing row
2935
- // (e.g. partial prior run left a record at the candidate ordinal under this
2936
- // EntityID — UQ_EntityField_EntityID_Sequence then fires on PG).
2937
- const candidate = 100000 + parentFields.indexOf(parentField);
2938
- const sequence = await this.nextAvailableEntityFieldSequence(pool, childEntity.ID, candidate);
3074
+ // Apply-time Sequence (see applyTimeEntityFieldSequenceSQL); reordered by
3075
+ // updateExistingEntityFieldsFromSchema afterwards.
2939
3076
  const q = (n) => this.qi(n);
2940
3077
  const sqlInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
2941
3078
  ${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
@@ -2947,9 +3084,10 @@ export class ManageMetadataBase {
2947
3084
  '${newFieldID}', '${childEntity.ID}', '${parentField.Name}',
2948
3085
  '${parentField.Type}', ${this.boolLit(parentField.AllowsNull)},
2949
3086
  ${parentField.Length}, ${parentField.Precision}, ${parentField.Scale},
2950
- ${sequence}, ${this.boolLit(true)}, ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)},
3087
+ ${this.applyTimeEntityFieldSequenceSQL(childEntity.ID)}, ${this.boolLit(true)}, ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)},
2951
3088
  ${this.utcNow()}, ${this.utcNow()})`;
2952
3089
  await this.LogSQLAndExecute(pool, sqlInsert, `Create IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
3090
+ ManageMetadataBase.registerNewField(childEntity.ID, parentField.Name);
2953
3091
  bUpdated = true;
2954
3092
  }
2955
3093
  }
@@ -3022,7 +3160,7 @@ export class ManageMetadataBase {
3022
3160
  RelatedEntityID IS NOT NULL AND
3023
3161
  IsVirtual = ${this.boolLit(false)} AND
3024
3162
  EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
3025
- ORDER BY RelatedEntityID`;
3163
+ ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
3026
3164
  const entityFieldsResult = await this.runQuery(pool, sSQL);
3027
3165
  const entityFields = entityFieldsResult.recordset;
3028
3166
  // Get the relationship counts for each entity
@@ -3200,7 +3338,7 @@ export class ManageMetadataBase {
3200
3338
  RelatedEntityID IS NOT NULL AND
3201
3339
  IsVirtual = ${this.boolLit(false)} AND
3202
3340
  EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
3203
- ORDER BY RelatedEntityID`;
3341
+ ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
3204
3342
  const entityFieldsResult = await this.runQuery(pool, sSQL);
3205
3343
  const entityFields = entityFieldsResult.recordset;
3206
3344
  const sSQLRelationship = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityRelationship')}`;
@@ -3430,7 +3568,7 @@ export class ManageMetadataBase {
3430
3568
  // AN: 14-June-2025 - See note below about the new order of these steps, this must
3431
3569
  // happen before we update existing entity fields from schema.
3432
3570
  const step2StartTime = new Date();
3433
- if (!await this.createNewEntityFieldsFromSchema(pool, scopedEntityIDs)) { // has its own internal filtering for exclude schema/table so don't pass in
3571
+ if (!await this.createNewEntityFieldsFromSchema(pool, scopedEntityIDs, excludeSchemas)) {
3434
3572
  logError('Error creating new entity fields from schema');
3435
3573
  bSuccess = false;
3436
3574
  }
@@ -3447,7 +3585,7 @@ export class ManageMetadataBase {
3447
3585
  }
3448
3586
  // AN: 14-June-2025 - we are now running this AFTER we create new entity fields from schema
3449
3587
  // which results in the same pattern of behavior as migrations where we first create new fields
3450
- // with VERY HIGH sequence numbers (e.g. 100,000 above what they will be approx) and then
3588
+ // at an apply-time MAX(Sequence)+ordinal placeholder (above every existing row) and then
3451
3589
  // we align them properly in sequential order from 1+ via this method below.
3452
3590
  const step3StartTime = new Date();
3453
3591
  if (!await this.updateExistingEntityFieldsFromSchema(pool, excludeSchemas, scopedEntityIDs)) {
@@ -3586,6 +3724,9 @@ export class ManageMetadataBase {
3586
3724
  try {
3587
3725
  logStatus(` Found ${configInfo.additionalSchemaInfo}, applying soft PK/FK configuration...`);
3588
3726
  const config = ManageMetadataBase.getSoftPKFKConfig();
3727
+ if (!config) {
3728
+ return true;
3729
+ }
3589
3730
  let totalPKs = 0;
3590
3731
  let totalFKs = 0;
3591
3732
  const schema = mj_core_schema();
@@ -3610,6 +3751,18 @@ export class ManageMetadataBase {
3610
3751
  const primaryKeys = table.PrimaryKey || [];
3611
3752
  if (primaryKeys.length > 0) {
3612
3753
  for (const pk of primaryKeys) {
3754
+ const checkSQL = `SELECT ${this.qi('IsPrimaryKey')}, ${this.qi('IsSoftPrimaryKey')}
3755
+ FROM ${this.qs(schema, 'EntityField')}
3756
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${pk.FieldName}'`;
3757
+ const checkRes = await this.runQuery(pool, checkSQL);
3758
+ if (checkRes.recordset.length > 0) {
3759
+ const row = checkRes.recordset[0];
3760
+ const isPk = row.IsPrimaryKey === true || row.IsPrimaryKey === 1 || row.IsPrimaryKey === '1';
3761
+ const isSoftPk = row.IsSoftPrimaryKey === true || row.IsSoftPrimaryKey === 1 || row.IsSoftPrimaryKey === '1';
3762
+ if (isPk && isSoftPk) {
3763
+ continue; // Already correctly set, skip write
3764
+ }
3765
+ }
3613
3766
  const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
3614
3767
  SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
3615
3768
  ${this.qi('IsPrimaryKey')} = ${this.boolLit(true)},
@@ -3635,11 +3788,29 @@ export class ManageMetadataBase {
3635
3788
  continue;
3636
3789
  }
3637
3790
  const relatedEntityId = relatedEntityResult.recordset[0].ID;
3791
+ const checkSQL = `SELECT ${this.qi('RelatedEntityID')}, ${this.qi('RelatedEntityFieldName')}, ${this.qi('IsSoftForeignKey')}, ${this.qi('AutoUpdateRelatedEntityInfo')}
3792
+ FROM ${this.qs(schema, 'EntityField')}
3793
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
3794
+ const checkRes = await this.runQuery(pool, checkSQL);
3795
+ if (checkRes.recordset.length > 0) {
3796
+ const row = checkRes.recordset[0];
3797
+ const curRelId = String(row.RelatedEntityID ?? '').trim().toLowerCase();
3798
+ const curRelField = String(row.RelatedEntityFieldName ?? '').trim().toLowerCase();
3799
+ const curSoftFk = row.IsSoftForeignKey === true || row.IsSoftForeignKey === 1 || row.IsSoftForeignKey === '1';
3800
+ const curAutoUpdate = row.AutoUpdateRelatedEntityInfo === true || row.AutoUpdateRelatedEntityInfo === 1 || row.AutoUpdateRelatedEntityInfo === '1';
3801
+ if (curRelId === String(relatedEntityId).trim().toLowerCase() &&
3802
+ curRelField === String(fk.RelatedField).trim().toLowerCase() &&
3803
+ curSoftFk &&
3804
+ !curAutoUpdate) {
3805
+ continue; // Already correctly set, skip write
3806
+ }
3807
+ }
3638
3808
  const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
3639
3809
  SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
3640
3810
  ${this.qi('RelatedEntityID')} = '${relatedEntityId}',
3641
3811
  ${this.qi('RelatedEntityFieldName')} = '${fk.RelatedField}',
3642
- ${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)}
3812
+ ${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)},
3813
+ ${this.qi('AutoUpdateRelatedEntityInfo')} = ${this.boolLit(false)}
3643
3814
  WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
3644
3815
  const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft FK for ${tableSchema}.${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
3645
3816
  if (result !== null) {
@@ -3672,7 +3843,7 @@ export class ManageMetadataBase {
3672
3843
  return true;
3673
3844
  try {
3674
3845
  // Load all existing EntityFieldValue rows for dedup checking
3675
- const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence`;
3846
+ const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence, Value, ID`;
3676
3847
  const allEFVResult = await this.runQuery(pool, efvSQL);
3677
3848
  const allEntityFieldValues = allEFVResult.recordset;
3678
3849
  let totalApplied = 0;
@@ -3899,7 +4070,8 @@ export class ManageMetadataBase {
3899
4070
  async dropExistingDefaultConstraint(pool, entity, fieldName) {
3900
4071
  try {
3901
4072
  const sqlDropDefaultConstraint = this.dbProvider.dropDefaultConstraintSQL(entity.SchemaName, entity.BaseTable, fieldName);
3902
- await this.LogSQLAndExecute(pool, sqlDropDefaultConstraint, `SQL text to drop default existing default constraints in entity ${entity.SchemaName}.${entity.BaseTable}`);
4073
+ // DECLARE-bearing block: close its batch in the replayable log (see SQLLogging.appendToSQLLogFile).
4074
+ await this.LogSQLAndExecute(pool, sqlDropDefaultConstraint, `SQL text to drop default existing default constraints in entity ${entity.SchemaName}.${entity.BaseTable}`, false, true, this.dbProvider.BatchSeparator);
3903
4075
  }
3904
4076
  catch (e) {
3905
4077
  logError(e);
@@ -3931,7 +4103,7 @@ export class ManageMetadataBase {
3931
4103
  // Use new API to generate entity description
3932
4104
  const result = await ag.generateEntityDescription(e, data[0].BaseTable, fields.map((f) => ({ Name: f.Name, Type: f.Type, IsNullable: f.AllowsNull, Description: f.Description })), currentUser);
3933
4105
  if (result?.entityDescription && result.entityDescription.length > 0) {
3934
- const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET Description = '${esc(result.entityDescription)}' WHERE Name = '${esc(e)}'`;
4106
+ const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET ${this.qi('Description')} = '${esc(result.entityDescription)}', ${this.qi('AutoUpdateDescription')} = ${this.boolLit(false)} WHERE ${this.qi('Name')} = '${esc(e)}'`;
3935
4107
  await this.LogSQLAndExecute(pool, sSQL, `SQL text to update entity description for entity ${e}`);
3936
4108
  }
3937
4109
  else {
@@ -4015,9 +4187,9 @@ export class ManageMetadataBase {
4015
4187
  *
4016
4188
  * @returns {string} - The SQL statement to retrieve pending entity fields.
4017
4189
  */
4018
- getPendingEntityFieldsSELECTSQL(entityIDs) {
4190
+ getPendingEntityFieldsSELECTSQL(entityIDs, excludeSchemas) {
4019
4191
  const schema = mj_core_schema();
4020
- return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs);
4192
+ return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs, excludeSchemas);
4021
4193
  }
4022
4194
  /**
4023
4195
  * This method builds a SQL Statement that will insert a row into the EntityField table with information about a new field.
@@ -4030,7 +4202,11 @@ export class ManageMetadataBase {
4030
4202
  const isPrimaryKey = n.FieldName?.trim().toLowerCase() === 'id';
4031
4203
  const isForeignKey = n.RelatedEntityID && n.RelatedEntityID.length > 0; // Foreign keys have RelatedEntityID set
4032
4204
  const isNameField = n.FieldName?.trim().toLowerCase() === 'name' || n.IsNameField;
4033
- const isEarlySequence = n.Sequence <= configInfo.newEntityDefaults?.IncludeFirstNFieldsAsDefaultInView;
4205
+ // The field's position in the schema (SourceOrdinal / column_id). `n.Sequence` is NOT that:
4206
+ // the pending-fields SELECT computes it as MAX+100000+ordinal, so comparing it against
4207
+ // IncludeFirstNFieldsAsDefaultInView could never be true and the setting was dead.
4208
+ const sourceOrdinal = typeof n.SourceOrdinal === 'number' && n.SourceOrdinal > 0 ? n.SourceOrdinal : 1;
4209
+ const isEarlySequence = sourceOrdinal <= configInfo.newEntityDefaults?.IncludeFirstNFieldsAsDefaultInView;
4034
4210
  const bDefaultInView = (isNameField || isEarlySequence) && !isPrimaryKey && !isForeignKey;
4035
4211
  const escapedDescription = n.Description ? `'${n.Description.replace(/'/g, "''")}'` : 'NULL';
4036
4212
  let fieldDisplayName = '';
@@ -4057,17 +4233,10 @@ export class ManageMetadataBase {
4057
4233
  : (parsedDefaultValue.trim().toLowerCase() === 'null' ? 'NULL' : `'${escapedParsedDefault}'`);
4058
4234
  const conflictCheck = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ID = '${newEntityFieldUUID}' OR (EntityID = '${n.EntityID}' AND Name = '${n.FieldName}')`;
4059
4235
  const guard = this.dbProvider.wrapInsertWithConflictGuard(conflictCheck);
4060
- // Sequence is the catalog ordinal of this column on the entity's BaseView
4061
- // (`SourceOrdinal` / column_id). Existing rows on the same entity are parked
4062
- // at Sequence+100000 first (see parkEntityFieldSequencesSQL) so this INSERT
4063
- // cannot collide on UQ_EntityField_EntityID_Sequence. Immediately after the
4064
- // batch, manageEntityFields calls spUpdateExistingEntityFieldsFromSchema
4065
- // which rewrites EVERY field on the entity from the live view — including
4066
- // parked rows. That proc must run AFTER views are current (CodeGen Pass 2,
4067
- // after SQL generation). Pass 1 still emits this SQL against whatever the
4068
- // view is at that moment; Pass 2 is the one that matches the finished BaseView.
4069
- const sourceOrdinal = typeof n.SourceOrdinal === 'number' && n.SourceOrdinal > 0 ? n.SourceOrdinal : 1;
4070
- const sequenceExpr = String(sourceOrdinal);
4236
+ // Sequence is an apply-time expression, never a literal: see applyTimeEntityFieldSequenceSQL.
4237
+ // `n.Sequence` (MAX+100000+ordinal from the pending SELECT) is only that query's ORDER BY key
4238
+ // and must never be written; `sourceOrdinal` above feeds DefaultInView only.
4239
+ const sequenceExpr = this.applyTimeEntityFieldSequenceSQL(n.EntityID);
4071
4240
  return `
4072
4241
  ${guard.prefix}
4073
4242
  INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')}
@@ -4121,7 +4290,7 @@ export class ManageMetadataBase {
4121
4290
  ${n.RelatedEntityID && n.RelatedEntityID.length > 0 ? `'${n.RelatedEntityID}'` : 'NULL'},
4122
4291
  ${n.RelatedEntityFieldName && n.RelatedEntityFieldName.length > 0 ? `'${n.RelatedEntityFieldName}'` : 'NULL'},
4123
4292
  ${this.boolLit(n.IsNameField !== null ? n.IsNameField : false)},
4124
- ${this.boolLit(n.FieldName === 'ID' || n.IsNameField)},
4293
+ ${this.boolLit(Boolean(n.IsNameField))},
4125
4294
  ${this.boolLit(n.RelatedEntityID && n.RelatedEntityID.length > 0)},
4126
4295
  ${this.boolLit(bDefaultInView)},
4127
4296
  ${this.boolLit(n.IsPrimaryKey)},
@@ -4141,45 +4310,20 @@ export class ManageMetadataBase {
4141
4310
  * @param sqlDefaultValue
4142
4311
  * @returns
4143
4312
  */
4144
- /**
4145
- * Park existing EntityField.Sequence values out of the 1..N catalog range so a
4146
- * following INSERT can use the real BaseView column_id without colliding on
4147
- * UQ_EntityField_EntityID_Sequence. The +100000 band is unique-safe; the
4148
- * subsequent spUpdateExistingEntityFieldsFromSchema rewrite brings every row
4149
- * (parked and new) back to live catalog order.
4150
- *
4151
- * Parks ONLY when nothing on the entity is parked yet, which is what makes a second
4152
- * emission for the same entity in the same run a no-op. `Sequence < 100000` alone does
4153
- * not achieve that: after the first park the rows below the band are precisely the ones
4154
- * the first pass just INSERTED at their catalog ordinals, so a second park lifts THOSE
4155
- * into the band — onto the row the first park moved from the same ordinal, and the
4156
- * migration dies on UQ_EntityField_EntityID_Sequence with a duplicate at 100000+ordinal.
4157
- * Reachable whenever an entity gains fields in both CodeGen passes: pass 1 for the real
4158
- * columns, pass 2 for the denormalized name column a new foreign key introduces.
4159
- */
4160
- parkEntityFieldSequencesSQL(entityID) {
4161
- const table = this.qs(mj_core_schema(), 'EntityField');
4162
- return `UPDATE ${table}
4163
- SET ${this.qi('Sequence')} = ${this.qi('Sequence')} + 100000
4164
- WHERE ${this.qi('EntityID')} = '${entityID}'
4165
- AND ${this.qi('Sequence')} < 100000
4166
- AND NOT EXISTS (
4167
- SELECT 1 FROM ${table}
4168
- WHERE ${this.qi('EntityID')} = '${entityID}'
4169
- AND ${this.qi('Sequence')} >= 100000
4170
- );`;
4171
- }
4172
4313
  parseDefaultValue(sqlDefaultValue) {
4173
4314
  if (sqlDefaultValue === null || sqlDefaultValue === undefined) {
4174
4315
  return null;
4175
4316
  }
4176
4317
  return this.dbProvider.parseColumnDefaultValue(sqlDefaultValue) ?? null;
4177
4318
  }
4178
- async createNewEntityFieldsFromSchema(pool, entityIDs) {
4319
+ async createNewEntityFieldsFromSchema(pool, entityIDs, excludeSchemas) {
4179
4320
  try {
4180
4321
  // entityIDs flows down to the provider's WHERE clause so the inline SELECT
4181
- // narrows to changed entities only when scoped.
4182
- const sSQL = this.getPendingEntityFieldsSELECTSQL(entityIDs);
4322
+ // narrows to changed entities only when scoped. excludeSchemas is the compiled
4323
+ // includeSchemas scope (out-of-allow-list schemas). Without it, Pass 1 inserts
4324
+ // pending fields for every schema in the database (Forms CodeGen emitted Common
4325
+ // Activity Files EntityField rows).
4326
+ const sSQL = this.getPendingEntityFieldsSELECTSQL(entityIDs, excludeSchemas);
4183
4327
  const newEntityFieldsResult = await this.runQuery(pool, sSQL);
4184
4328
  const newEntityFields = newEntityFieldsResult.recordset;
4185
4329
  if (newEntityFields.length > 0) {
@@ -4197,18 +4341,14 @@ export class ManageMetadataBase {
4197
4341
  // Batch size is configurable via `metadataInsertBatchSize` (default 250).
4198
4342
  const CHUNK_SIZE = configInfo.metadataInsertBatchSize ?? 250;
4199
4343
  const inserts = [];
4200
- const parkedEntityIDs = new Set();
4201
4344
  for (let i = 0; i < newEntityFields.length; ++i) {
4202
4345
  const n = newEntityFields[i];
4203
4346
  if (n.EntityID !== null && n.EntityID !== undefined && n.EntityID.length > 0) {
4204
4347
  // need to check for null entity id = that is because the above query can return candidate Entity Fields but the entities may not have been created if the entities
4205
4348
  // that would have been created violate rules - such as not having an ID column, etc.
4206
- if (!parkedEntityIDs.has(n.EntityID)) {
4207
- inserts.push(this.parkEntityFieldSequencesSQL(n.EntityID));
4208
- parkedEntityIDs.add(n.EntityID);
4209
- }
4210
4349
  const newEntityFieldUUID = this.createNewUUID();
4211
4350
  inserts.push(this.getPendingEntityFieldINSERTSQL(newEntityFieldUUID, n));
4351
+ ManageMetadataBase.registerNewField(n.EntityID, n.FieldName);
4212
4352
  }
4213
4353
  }
4214
4354
  for (let i = 0; i < inserts.length; i += CHUNK_SIZE) {
@@ -4304,11 +4444,20 @@ export class ManageMetadataBase {
4304
4444
  const label = isScoped
4305
4445
  ? `SQL text to update existing entity fields from schema (${entityIDs.length} scoped entities)`
4306
4446
  : `SQL text to update existing entity fields from schema`;
4307
- const result = await this.LogSQLAndExecute(pool, sSQL, label, true);
4308
- // result contains the updated entity fields. Get a distinct list of entity names
4309
- // and add them to the modified entity list if they're not already in there.
4310
- if (result && result.length > 0) {
4311
- ManageMetadataBase.addNewEntitiesToModifiedList(result.map((r) => r.EntityName));
4447
+ const before = await this.snapshotEntityFields(pool, entityIDs);
4448
+ await this.LogSQLAndExecute(pool, sSQL, label, true);
4449
+ const after = await this.snapshotEntityFields(pool, entityIDs);
4450
+ const changes = diffEntityFieldSnapshots(before, after, (e, n) => ManageMetadataBase.isFieldNew(e, n));
4451
+ if (changes.length > 0) {
4452
+ ManageMetadataBase.addNewEntitiesToModifiedList(changes.map(c => c.entityName));
4453
+ for (const c of changes) {
4454
+ ManageMetadataBase.registerFieldChange(c.entityID, c.fieldName, c.reasons);
4455
+ ManageMetadataBase._changedFieldReport.push({
4456
+ entityName: c.entityName,
4457
+ fieldName: c.fieldName,
4458
+ reasons: [...c.reasons],
4459
+ });
4460
+ }
4312
4461
  }
4313
4462
  return true;
4314
4463
  }
@@ -4461,13 +4610,22 @@ export class ManageMetadataBase {
4461
4610
  const sSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFieldsWithCheckConstraints')}${filter}`;
4462
4611
  const resultResult = await this.runQuery(pool, sSQL);
4463
4612
  const result = resultResult.recordset;
4464
- const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence`;
4613
+ const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence, Value, ID`;
4465
4614
  const allEntityFieldValuesResult = await this.runQuery(pool, efvSQL);
4466
4615
  const allEntityFieldValues = allEntityFieldValuesResult.recordset;
4467
- const efSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} ORDER BY EntityID, Sequence`;
4616
+ const efSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} ORDER BY EntityID, Sequence, Name, ID`;
4468
4617
  const allEntityFieldsResult = await this.runQuery(pool, efSQL);
4469
4618
  const allEntityFields = allEntityFieldsResult.recordset;
4470
4619
  const generationPromises = [];
4620
+ const ag = new AdvancedGeneration();
4621
+ // `skipDBUpdate` means load-only: `runValidationGeneration` is called below with
4622
+ // `generateNewCode = false`, and `generateValidatorFunctionFromCheckConstraint` only reaches an
4623
+ // LLM when that flag is true. Reading a validator back out of an Approved `GeneratedCode` record
4624
+ // is therefore a plain database read — gating it on the AI feature flag is what made
4625
+ // `mj codegen --no-ai` DELETE every committed `Validate()` override rather than preserve it, and
4626
+ // the `codegen-drift` gate (which runs `--no-ai`) then demanded that lossy output. Generation
4627
+ // stays gated; only the read is unconditional.
4628
+ const emitValidators = skipDBUpdate || ag.featureEnabled('ParseCheckConstraints');
4471
4629
  const columnLevelResults = result.filter((r) => r.EntityFieldID); // get the column level constraints
4472
4630
  const tableLevelResults = result.filter((r) => !r.EntityFieldID); // get the table level constraints
4473
4631
  for (const r of columnLevelResults) {
@@ -4501,20 +4659,20 @@ export class ManageMetadataBase {
4501
4659
  else {
4502
4660
  // if we get here that means we don't have a simple condition in the check constraint that the RegEx could parse. If Advanced Generation is enabled, we will
4503
4661
  // attempt to use an LLM to do things fancier now
4504
- if (configInfo.advancedGeneration?.enableAdvancedGeneration &&
4505
- configInfo.advancedGeneration?.features.find(f => f.name === 'ParseCheckConstraints' && f.enabled)) {
4506
- // the user has the feature turned on, let's generate a description of the constraint and then build a Validate function for the constraint
4662
+ if (emitValidators) {
4663
+ // either we are loading persisted validators, or the feature is on and we may generate new ones
4507
4664
  // run this in parallel
4508
4665
  generationPromises.push(this.runValidationGeneration(r, allEntityFields, !skipDBUpdate, currentUser));
4509
4666
  }
4510
4667
  }
4511
4668
  }
4512
4669
  }
4513
- // now for the table level constraints run the process for advanced generation
4670
+ // now for the table level constraints, build a Validate function for each constraint.
4671
+ // As above: no featureEnabled() gate here. Loading previously-generated code is not an AI operation, and the
4672
+ // ParseCheckConstraints gate that does guard the LLM call lives inside generateValidatorFunctionFromCheckConstraint.
4514
4673
  for (const r of tableLevelResults) {
4515
- if (configInfo.advancedGeneration?.enableAdvancedGeneration &&
4516
- configInfo.advancedGeneration?.features.find(f => f.name === 'ParseCheckConstraints' && f.enabled)) {
4517
- // the user has the feature turned on, let's generate a description of the constraint and then build a Validate function for the constraint
4674
+ if (emitValidators) {
4675
+ // either we are loading persisted validators, or the feature is on and we may generate new ones
4518
4676
  // run this in parallel
4519
4677
  generationPromises.push(this.runValidationGeneration(r, allEntityFields, !skipDBUpdate, currentUser));
4520
4678
  }
@@ -4585,9 +4743,9 @@ export class ManageMetadataBase {
4585
4743
  }
4586
4744
  }
4587
4745
  try {
4588
- if (generateNewCode && configInfo.advancedGeneration?.enableAdvancedGeneration && configInfo.advancedGeneration?.features.find(f => f.name === 'ParseCheckConstraints' && f.enabled)) {
4746
+ const ag = new AdvancedGeneration();
4747
+ if (generateNewCode && ag.featureEnabled('ParseCheckConstraints')) {
4589
4748
  // feature is enabled, so let's call the AI to generate a function for us
4590
- const ag = new AdvancedGeneration();
4591
4749
  const entityFieldListInfo = allEntityFields.filter(item => item.Entity.trim().toLowerCase() === data.EntityName.trim().toLowerCase()).map(item => ` * ${item.Name} - ${item.Type}${item.AllowsNull ? ' (nullable)' : ' (not null)'}`).join('\n');
4592
4750
  // Use new API to parse check constraint
4593
4751
  const result = await ag.parseCheckConstraint(constraintDefinition, entityFieldListInfo, generatedValidationFunctionName, currentUser);
@@ -4806,6 +4964,10 @@ export class ManageMetadataBase {
4806
4964
  if (newEntities && newEntities.length > 0) {
4807
4965
  const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
4808
4966
  const transaction = await pool.beginTransaction();
4967
+ // The in-run list of new entity names is process-static. Names pushed by a batch that is then
4968
+ // rolled back must not survive into the next run of the same process (in-process CodeGen runs
4969
+ // many times per process), or every one of them reads as "taken" and gets a schema suffix.
4970
+ const newEntityNamesBefore = ManageMetadataBase.newEntityList.length;
4809
4971
  try {
4810
4972
  // wrap in a transaction so we get all of it or none of it
4811
4973
  for (let i = 0; i < newEntities.length; ++i) {
@@ -4816,6 +4978,7 @@ export class ManageMetadataBase {
4816
4978
  }
4817
4979
  catch (e) {
4818
4980
  await transaction.rollback();
4981
+ ManageMetadataBase.newEntityList.length = newEntityNamesBefore;
4819
4982
  throw e;
4820
4983
  }
4821
4984
  if (ManageMetadataBase.newEntityList.length > 0) {
@@ -4924,11 +5087,13 @@ export class ManageMetadataBase {
4924
5087
  }
4925
5088
  async newEntityNameWithAdvancedGeneration(ag, newEntity, currentUser) {
4926
5089
  const result = await ag.generateEntityName(newEntity.TableName, currentUser);
4927
- if (result?.entityName) {
5090
+ // Checked here as well as inside generateEntityName: a subclass or a stub can return anything, and
5091
+ // a non-name that reaches the INSERT fails it (see createNewEntity's catch for why that used to be silent).
5092
+ if (result && isPlausibleEntityName(result.entityName)) {
4928
5093
  return this.markupEntityName(newEntity.SchemaName, result.entityName);
4929
5094
  }
4930
5095
  else {
4931
- console.warn(' >>> Advanced Generation Error: LLM returned invalid result, falling back to simple generated entity name');
5096
+ console.warn(` >>> Advanced Generation Error: LLM returned an unusable entity name for ${newEntity.SchemaName}.${newEntity.TableName}, falling back to simple generated entity name`);
4932
5097
  return this.simpleNewEntityName(newEntity.SchemaName, newEntity.TableName);
4933
5098
  }
4934
5099
  }
@@ -5102,9 +5267,7 @@ export class ManageMetadataBase {
5102
5267
  for (const p of permissions) {
5103
5268
  const RoleID = md.Roles.find(r => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
5104
5269
  if (RoleID) {
5105
- const sSQLInsertPermission = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityPermission')}
5106
- (${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
5107
- ('${newEntityID}', '${RoleID}', ${this.boolLit(p.CanRead)}, ${this.boolLit(p.CanCreate)}, ${this.boolLit(p.CanUpdate)}, ${this.boolLit(p.CanDelete)}, ${this.utcNow()}, ${this.utcNow()})`;
5270
+ const sSQLInsertPermission = this.buildEntityPermissionInsertSQL(newEntityID, RoleID, p.CanRead, p.CanCreate, p.CanUpdate, p.CanDelete);
5108
5271
  await this.LogSQLAndExecute(pool, sSQLInsertPermission, `SQL generated to add new permission for entity ${newEntityName} for role ${p.RoleName}`);
5109
5272
  }
5110
5273
  else
@@ -5125,6 +5288,15 @@ export class ManageMetadataBase {
5125
5288
  if (errStack) {
5126
5289
  LogError(` Stack trace: ${errStack}`);
5127
5290
  }
5291
+ // Rethrow so createNewEntities stops, rolls back, and the run fails. Swallowing here let CodeGen
5292
+ // commit a PARTIAL set of new entities and report success: the table whose INSERT failed was simply
5293
+ // absent afterwards, with a log line as the only witness. It has bitten twice — a UQ_Entity_Name
5294
+ // collision (see resolveUniqueEntityName) and an AI-generated name of `-1` that dropped eleven of a
5295
+ // connector's twenty-seven tables while the schema update reported "complete". The transaction in
5296
+ // createNewEntities exists to give "all of it or none of it"; it can only do that if a failure
5297
+ // reaches it. A table that does not QUALIFY (no primary key) is still a skip, not a failure — that
5298
+ // path returns above and never gets here.
5299
+ throw e instanceof Error ? e : new Error(errMsg);
5128
5300
  }
5129
5301
  }
5130
5302
  async isSchemaNew(pool, schemaName) {
@@ -5275,6 +5447,35 @@ export class ManageMetadataBase {
5275
5447
  LogError(` >>>> WARNING: No application found for schema ${schemaName} to add entity ${entityName}`);
5276
5448
  }
5277
5449
  }
5450
+ /**
5451
+ * INSERT for one `EntityPermission` row, skipped when a row already exists for that
5452
+ * (EntityID, RoleID, Type).
5453
+ *
5454
+ * The guard is not defensive tidiness — `EntityPermission` carries a UNIQUE constraint on those
5455
+ * three columns (`UQ_EntityPermission_EntityID_RoleID_Type`), so an unguarded INSERT is a failed
5456
+ * CodeGen run rather than a duplicate row. All three call sites are "grant the configured default
5457
+ * permissions", which is naturally re-entrant: an entity re-detected as new, or a second CodeGen
5458
+ * pass over the same entity, reaches them again. Before the constraint existed this silently
5459
+ * accumulated duplicates — a live database showed one (entity, role) pair with rows created three
5460
+ * years apart, and pairs whose verb flags disagreed.
5461
+ *
5462
+ * `Type` is written explicitly rather than left to the column default so the row being inserted
5463
+ * and the row being tested for are keyed identically; a default that changed later would
5464
+ * otherwise put them out of step. Expressed as INSERT ... SELECT ... WHERE NOT EXISTS, which is
5465
+ * valid on both SQL Server and PostgreSQL, so no provider branch is needed.
5466
+ */
5467
+ buildEntityPermissionInsertSQL(entityId, roleId, canRead, canCreate, canUpdate, canDelete) {
5468
+ const table = this.qs(mj_core_schema(), 'EntityPermission');
5469
+ const entityLit = this.uuidLit(entityId);
5470
+ const roleLit = this.uuidLit(roleId);
5471
+ return `INSERT INTO ${table}
5472
+ (${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('Type')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')})
5473
+ SELECT ${entityLit}, ${roleLit}, 'Allow', ${this.boolLit(canRead)}, ${this.boolLit(canCreate)}, ${this.boolLit(canUpdate)}, ${this.boolLit(canDelete)}, ${this.utcNow()}, ${this.utcNow()}
5474
+ WHERE NOT EXISTS (
5475
+ SELECT 1 FROM ${table}
5476
+ WHERE ${this.qi('EntityID')} = ${entityLit} AND ${this.qi('RoleID')} = ${roleLit} AND ${this.qi('Type')} = 'Allow'
5477
+ )`;
5478
+ }
5278
5479
  /**
5279
5480
  * Adds default permissions for a newly created entity based on config settings.
5280
5481
  * Shared by both table-backed entity creation and virtual entity creation.
@@ -5288,9 +5489,7 @@ export class ManageMetadataBase {
5288
5489
  for (const p of permissions) {
5289
5490
  const RoleID = md.Roles.find(r => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
5290
5491
  if (RoleID) {
5291
- const sSQLInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityPermission')}
5292
- (${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
5293
- ('${entityId}', '${RoleID}', ${this.boolLit(p.CanRead)}, ${this.boolLit(p.CanCreate)}, ${this.boolLit(p.CanUpdate)}, ${this.boolLit(p.CanDelete)}, ${this.utcNow()}, ${this.utcNow()})`;
5492
+ const sSQLInsert = this.buildEntityPermissionInsertSQL(entityId, RoleID, p.CanRead, p.CanCreate, p.CanUpdate, p.CanDelete);
5294
5493
  await this.LogSQLAndExecute(pool, sSQLInsert, `SQL generated to add permission for entity ${entityName} for role ${p.RoleName}`);
5295
5494
  }
5296
5495
  else {
@@ -5452,9 +5651,7 @@ export class ManageMetadataBase {
5452
5651
  logStatus(` > Materialized entity "${entityName}": role "${p.RoleName}" NOT granted read (it cannot read every source entity; the snapshot has no row-level scoping).`);
5453
5652
  continue;
5454
5653
  }
5455
- const sSQLInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityPermission')}
5456
- (${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
5457
- ('${entityId}', '${roleId}', ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)}, ${this.boolLit(false)}, ${this.utcNow()}, ${this.utcNow()})`;
5654
+ const sSQLInsert = this.buildEntityPermissionInsertSQL(entityId, roleId, true, false, false, false);
5458
5655
  await this.LogSQLAndExecute(pool, sSQLInsert, `SQL generated to add read permission for materialized entity ${entityName} for role ${p.RoleName}`);
5459
5656
  }
5460
5657
  }
@@ -5486,7 +5683,10 @@ export class ManageMetadataBase {
5486
5683
  return ManageMetadataBase.EntityHasRoleReadRLS(entity);
5487
5684
  }
5488
5685
  /** Pure form of the role-RLS layer (see {@link entityHasRowLevelSecurity}). IO-free so it can be reused
5489
- * verbatim by the runtime refresher's equivalent gate. */
5686
+ * verbatim by the runtime refresher's equivalent gate. Deliberately WIDER than the runtime's own reader
5687
+ * (`EntityInfo.GetUserRowLevelSecurityInfo` collects a filter only from an Allow row whose `CanRead` is
5688
+ * set, since #4358): a leftover filter beside a cleared flag still counts as "protected" here, which errs
5689
+ * conservative for a leak gate. */
5490
5690
  static EntityHasRoleReadRLS(entity) {
5491
5691
  return entity.Permissions.some((p) => !!p.ReadRLSFilterID && p.ReadRLSFilterID.trim().length > 0);
5492
5692
  }
@@ -5775,7 +5975,8 @@ export class ManageMetadataBase {
5775
5975
  WHERE
5776
5976
  ${whereClause}
5777
5977
  ORDER BY
5778
- e.Name
5978
+ e.Name,
5979
+ e.ID
5779
5980
  `;
5780
5981
  const entitiesResult = await this.runQuery(pool, entitiesSQL);
5781
5982
  const entities = entitiesResult.recordset;
@@ -5801,11 +6002,16 @@ export class ManageMetadataBase {
5801
6002
  ef.AutoUpdateIncludeInUserSearchAPI,
5802
6003
  ef.AutoUpdateCategory,
5803
6004
  ef.AutoUpdateDisplayName,
6005
+ ef.AutoUpdateExtendedType,
6006
+ ef.ExtendedType,
6007
+ ef.CodeType,
6008
+ ef.GeneratedFormSection,
5804
6009
  ef.EntityIDFieldName,
5805
6010
  ef.RelatedEntity,
5806
6011
  ef.IsVirtual,
5807
6012
  ef.AllowUpdateAPI,
5808
6013
  ef.IsNameField,
6014
+ ef.ValueListType,
5809
6015
  ef.DefaultInView,
5810
6016
  ef.IncludeInUserSearchAPI,
5811
6017
  ef.UserSearchPredicateAPI,
@@ -5819,7 +6025,9 @@ export class ManageMetadataBase {
5819
6025
  ef.EntityID IN (${entityIds})
5820
6026
  ORDER BY
5821
6027
  ef.EntityID,
5822
- ef.Sequence
6028
+ ef.Sequence,
6029
+ ef.Name,
6030
+ ef.ID
5823
6031
  `;
5824
6032
  const fieldsResult = await this.runQuery(pool, fieldsSQL);
5825
6033
  const allFields = fieldsResult.recordset;
@@ -5834,6 +6042,9 @@ export class ManageMetadataBase {
5834
6042
  WHERE
5835
6043
  es.EntityID IN (${entityIds})
5836
6044
  AND es.Name = 'FieldCategoryInfo'
6045
+ ORDER BY
6046
+ es.EntityID,
6047
+ es.Name
5837
6048
  `;
5838
6049
  const settingsResult = await this.runQuery(pool, settingsSQL);
5839
6050
  const allSettings = settingsResult.recordset;
@@ -5931,16 +6142,26 @@ export class ManageMetadataBase {
5931
6142
  // Fields for this entity — O(1) lookup into the pre-grouped map (keyed on the normalized
5932
6143
  // EntityID) instead of a linear scan of the full pooled array.
5933
6144
  const fields = fieldsByEntity.get(String(entity.ID ?? '').trim().toLowerCase()) ?? [];
6145
+ // Decorate fields with change state from field tracking (C1/C3)
6146
+ for (const f of fields) {
6147
+ f.IsNew = ManageMetadataBase.isFieldNew(entity.ID, f.Name);
6148
+ f.DescriptionReopened = ManageMetadataBase.isDisplayNameReopened(entity.ID, f.Name);
6149
+ f.TypeReopened = ManageMetadataBase.isTypeReopened(entity.ID, f.Name);
6150
+ }
6151
+ const newFieldNames = new Set(fields.filter((f) => f.IsNew).map((f) => String(f.Name)));
6152
+ const typeReopenedNames = new Set(fields.filter((f) => f.TypeReopened).map((f) => String(f.Name)));
6153
+ const hasNewFields = newFieldNames.size > 0;
6154
+ const hasTypeReopened = typeReopenedNames.size > 0;
6155
+ const hasDescriptionReopened = fields.some((f) => f.DescriptionReopened);
5934
6156
  // Determine if this is a new entity (for DefaultForNewUser decision)
5935
6157
  const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
5936
- // Smart Field Identification
5937
- // Only run if at least one field allows auto-update for any of the smart field properties,
5938
- // or if entity-level search/FTS auto-update flags are set
5939
- const needsFieldAnalysis = fields.some((f) => f.AutoUpdateIsNameField || f.AutoUpdateDefaultInView || f.AutoUpdateIncludeInUserSearchAPI || f.AutoUpdateUserSearchPredicate || f.AutoUpdateFullTextSearch);
6158
+ // Smart Field Identification — FM4. Runs only when there is something new to decide about.
6159
+ const needsFieldAnalysis = (isNewEntity || hasNewFields || hasTypeReopened) && fields.some((f) => f.AutoUpdateIsNameField || f.AutoUpdateDefaultInView || f.AutoUpdateIncludeInUserSearchAPI || f.AutoUpdateUserSearchPredicate || f.AutoUpdateFullTextSearch);
5940
6160
  // entity-level auto-update flags come from the raw SQL query, not EntityInfo
5941
6161
  const entityRecord = entity;
5942
- const needsEntitySearchConfig = entityRecord.AutoUpdateAllowUserSearchAPI || entityRecord.AutoUpdateFullTextSearch;
6162
+ const needsEntitySearchConfig = isNewEntity && (Boolean(entityRecord.AutoUpdateAllowUserSearchAPI) || Boolean(entityRecord.AutoUpdateFullTextSearch));
5943
6163
  if (needsFieldAnalysis || needsEntitySearchConfig) {
6164
+ CodeGenReporter.Instance.counter('ai.smartFieldCalls');
5944
6165
  const fieldAnalysis = await ag.identifyFields({
5945
6166
  Name: entity.Name,
5946
6167
  Description: entity.Description,
@@ -5955,13 +6176,17 @@ export class ManageMetadataBase {
5955
6176
  AutoUpdateAllowUserSearchAPI: entityRecord.AutoUpdateAllowUserSearchAPI,
5956
6177
  FullTextSearchEnabled: entityRecord.FullTextSearchEnabled,
5957
6178
  AutoUpdateFullTextSearch: entityRecord.AutoUpdateFullTextSearch,
5958
- }, fields, fieldAnalysis);
6179
+ }, fields, fieldAnalysis, { isNewEntity, newFieldNames, typeReopenedNames });
5959
6180
  }
5960
6181
  }
5961
6182
  // Form Layout Generation
5962
- // Only run if at least one field allows auto-update
5963
- const needsCategoryGeneration = fields.some((f) => f.AutoUpdateCategory && (!f.Category || f.Category.trim() === ''));
6183
+ // Fires only on change events (new entity, new fields, reopened description/type) AND when there is work to do
6184
+ // (blank categories with AutoUpdateCategory=1, or reopened fields to review). Gated on events so omitted blank fields don't loop (§3.4).
6185
+ const hasCategoryNeeded = fields.some((f) => Boolean(f.AutoUpdateCategory) && (!f.Category || String(f.Category).trim() === ''));
6186
+ const needsCategoryGeneration = (isNewEntity || hasNewFields || hasDescriptionReopened || hasTypeReopened)
6187
+ && (hasCategoryNeeded || hasDescriptionReopened || hasTypeReopened);
5964
6188
  if (needsCategoryGeneration) {
6189
+ CodeGenReporter.Instance.counter('ai.formLayoutCalls');
5965
6190
  // Build IS-A parent chain context if this entity has a parent
5966
6191
  const parentChainContext = this.buildParentChainContext(entity, fields);
5967
6192
  const layoutAnalysis = await ag.generateFormLayout({
@@ -6046,19 +6271,21 @@ export class ManageMetadataBase {
6046
6271
  /**
6047
6272
  * Apply smart field identification results to entity fields and entity-level search configuration
6048
6273
  */
6049
- async applySmartFieldIdentification(pool, entity, fields, result) {
6274
+ async applySmartFieldIdentification(pool, entity, fields, result, ctx) {
6050
6275
  // Run the guardrail pipeline once up front so every downstream applier
6051
6276
  // sees the same already-normalized result. See search-guardrails.ts for
6052
6277
  // the rules. Mutates result in place — see normalizeSearchFlagsInPlace.
6053
6278
  this.normalizeSearchFlagsInPlace(entity, fields, result);
6054
6279
  const sqlStatements = [];
6055
- this.applyNameFieldUpdates(sqlStatements, fields, result);
6056
- this.applyDefaultInViewUpdates(sqlStatements, fields, result);
6057
- this.applySearchableFieldUpdates(sqlStatements, fields, result, entity);
6058
- this.applySearchPredicateUpdates(sqlStatements, fields, result);
6059
- this.applyEntitySearchConfig(sqlStatements, entity, result);
6280
+ this.applyNameFieldUpdates(sqlStatements, fields, result, entity);
6281
+ this.applyDefaultInViewUpdates(sqlStatements, fields, result, entity, ctx);
6282
+ this.applySearchableFieldUpdates(sqlStatements, fields, result, entity, ctx);
6283
+ this.applySearchPredicateUpdates(sqlStatements, fields, result, entity, ctx);
6284
+ if (!ctx || ctx.isNewEntity) {
6285
+ this.applyEntitySearchConfig(sqlStatements, entity, result);
6286
+ }
6060
6287
  if (configInfo.advancedGeneration?.allowFullTextSearchAutoUpdate) {
6061
- this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result);
6288
+ this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result, ctx);
6062
6289
  }
6063
6290
  // Execute all updates in one batch
6064
6291
  if (sqlStatements.length > 0) {
@@ -6197,11 +6424,11 @@ export class ManageMetadataBase {
6197
6424
  * `AutoUpdateIsNameField=0` are never touched in either direction — the user owns
6198
6425
  * them, and `EntityInfo.NameField`'s literal-`Name` preference arbitrates at runtime.
6199
6426
  */
6200
- applyNameFieldUpdates(sqlStatements, fields, result) {
6427
+ applyNameFieldUpdates(sqlStatements, fields, result, entity) {
6201
6428
  const nameFieldNames = result.nameFields ?? [];
6202
6429
  // Surface LLM candidates that don't exist on the entity (same diagnostics as before).
6203
6430
  for (const nfName of nameFieldNames) {
6204
- if (!fields.some(f => f.Name === nfName)) {
6431
+ if (!this.findFieldByName(fields, nfName)) {
6205
6432
  logError(`Smart field identification returned invalid nameField: '${nfName}' not found in entity fields`);
6206
6433
  }
6207
6434
  }
@@ -6247,7 +6474,7 @@ export class ManageMetadataBase {
6247
6474
  return this.preferLiteralNameField(eligibleFlagged);
6248
6475
  }
6249
6476
  for (const candidateName of rankedCandidates) {
6250
- const candidate = fields.find(f => f.Name === candidateName);
6477
+ const candidate = this.findFieldByName(fields, candidateName);
6251
6478
  if (candidate && this.isFieldEligibleForNameField(candidate)) {
6252
6479
  return candidate;
6253
6480
  }
@@ -6355,12 +6582,15 @@ export class ManageMetadataBase {
6355
6582
  /**
6356
6583
  * Generate SQL UPDATEs for DefaultInView on the identified default view fields
6357
6584
  */
6358
- applyDefaultInViewUpdates(sqlStatements, fields, result) {
6585
+ applyDefaultInViewUpdates(sqlStatements, fields, result, entityOrCtx, maybeCtx) {
6586
+ const entity = (entityOrCtx && 'Name' in entityOrCtx) ? entityOrCtx : undefined;
6587
+ const ctx = (entityOrCtx && 'newFieldNames' in entityOrCtx) ? entityOrCtx : maybeCtx;
6588
+ const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
6359
6589
  // Guard against a malformed LLM result that omits the array entirely.
6360
6590
  // Mirrors the defensive handling in the sibling apply* methods.
6361
6591
  const defaultInView = result.defaultInView ?? [];
6362
- const defaultInViewFields = fields.filter(f => defaultInView.includes(f.Name) && f.AutoUpdateDefaultInView && f.ID);
6363
- const missingFields = defaultInView.filter(name => !fields.some(f => f.Name === name));
6592
+ const defaultInViewFields = fields.filter(f => defaultInView.some(n => n.toLowerCase() === String(f.Name ?? '').toLowerCase()) && f.AutoUpdateDefaultInView && f.ID && inScope(f));
6593
+ const missingFields = defaultInView.filter(name => !this.findFieldByName(fields, name));
6364
6594
  if (missingFields.length > 0) {
6365
6595
  logError(`Smart field identification returned invalid defaultInView fields: ${missingFields.join(', ')} not found in entity`);
6366
6596
  }
@@ -6386,12 +6616,13 @@ export class ManageMetadataBase {
6386
6616
  * always-wrong cases — they cause the data provider to emit unindexed scans on
6387
6617
  * every search.
6388
6618
  */
6389
- applySearchableFieldUpdates(sqlStatements, fields, result, entity) {
6619
+ applySearchableFieldUpdates(sqlStatements, fields, result, entity, ctx) {
6620
+ const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
6390
6621
  if (!result.searchableFields || result.searchableFields.length === 0) {
6391
6622
  return;
6392
6623
  }
6393
- const searchableFields = fields.filter(f => result.searchableFields.includes(f.Name) && f.AutoUpdateIncludeInUserSearchAPI && f.ID);
6394
- const missingSearchableFields = result.searchableFields.filter(name => !fields.some(f => f.Name === name));
6624
+ const searchableFields = fields.filter(f => result.searchableFields.some(n => n.toLowerCase() === String(f.Name ?? '').toLowerCase()) && f.AutoUpdateIncludeInUserSearchAPI && f.ID && inScope(f));
6625
+ const missingSearchableFields = result.searchableFields.filter(name => !this.findFieldByName(fields, name));
6395
6626
  if (missingSearchableFields.length > 0) {
6396
6627
  logError(`Smart field identification returned invalid searchableFields: ${missingSearchableFields.join(', ')} not found in entity`);
6397
6628
  }
@@ -6426,7 +6657,7 @@ export class ManageMetadataBase {
6426
6657
  if (!textTypes.has(type))
6427
6658
  return false;
6428
6659
  // Unbounded text on a non-FTX entity cannot be index-seeked.
6429
- const length = field.Length;
6660
+ const length = (field.Length ?? field.MaxLength);
6430
6661
  if (length === -1 && !ftxEnabled)
6431
6662
  return false;
6432
6663
  return true;
@@ -6434,17 +6665,20 @@ export class ManageMetadataBase {
6434
6665
  /**
6435
6666
  * Generate SQL UPDATEs for UserSearchPredicateAPI on each searchable field
6436
6667
  */
6437
- applySearchPredicateUpdates(sqlStatements, fields, result) {
6668
+ applySearchPredicateUpdates(sqlStatements, fields, result, entityOrCtx, maybeCtx) {
6669
+ const entity = (entityOrCtx && 'Name' in entityOrCtx) ? entityOrCtx : undefined;
6670
+ const ctx = (entityOrCtx && 'newFieldNames' in entityOrCtx) ? entityOrCtx : maybeCtx;
6671
+ const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
6438
6672
  if (!result.searchPredicates || result.searchPredicates.length === 0) {
6439
6673
  return;
6440
6674
  }
6441
6675
  for (const sp of result.searchPredicates) {
6442
- const field = fields.find(f => f.Name === sp.field);
6676
+ const field = this.findFieldByName(fields, sp.field);
6443
6677
  if (!field) {
6444
6678
  logError(`Smart field identification returned invalid searchPredicate field: '${sp.field}' not found in entity fields`);
6445
6679
  continue;
6446
6680
  }
6447
- if (!field.AutoUpdateUserSearchPredicate || !field.ID) {
6681
+ if (!field.AutoUpdateUserSearchPredicate || !field.ID || !inScope(field)) {
6448
6682
  continue;
6449
6683
  }
6450
6684
  // Only update if the current value differs from the recommended predicate
@@ -6532,9 +6766,10 @@ export class ManageMetadataBase {
6532
6766
  /**
6533
6767
  * Generate SQL UPDATEs for entity-level and field-level FullTextSearch configuration
6534
6768
  */
6535
- applyFullTextSearchUpdates(sqlStatements, entity, fields, result) {
6536
- // Entity-level FullTextSearchEnabled
6537
- if (result.enableFullTextSearch != null && entity.AutoUpdateFullTextSearch) {
6769
+ applyFullTextSearchUpdates(sqlStatements, entity, fields, result, ctx) {
6770
+ const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
6771
+ // Entity-level FullTextSearchEnabled (only for new entities)
6772
+ if ((!ctx || ctx.isNewEntity) && result.enableFullTextSearch != null && entity.AutoUpdateFullTextSearch) {
6538
6773
  const newValue = result.enableFullTextSearch;
6539
6774
  const currentValue = !!entity.FullTextSearchEnabled;
6540
6775
  if (newValue !== currentValue) {
@@ -6551,12 +6786,12 @@ export class ManageMetadataBase {
6551
6786
  return;
6552
6787
  }
6553
6788
  for (const ftsFieldName of result.fullTextSearchFields) {
6554
- const field = fields.find(f => f.Name === ftsFieldName);
6789
+ const field = this.findFieldByName(fields, ftsFieldName);
6555
6790
  if (!field) {
6556
6791
  logError(`Smart field identification returned invalid fullTextSearchField: '${ftsFieldName}' not found in entity fields`);
6557
6792
  continue;
6558
6793
  }
6559
- if (!field.AutoUpdateFullTextSearch || !field.ID) {
6794
+ if (!field.AutoUpdateFullTextSearch || !field.ID || !inScope(field)) {
6560
6795
  continue;
6561
6796
  }
6562
6797
  if (!field.FullTextSearchEnabled) {
@@ -6583,29 +6818,29 @@ export class ManageMetadataBase {
6583
6818
  // Defensive: a malformed LLM result can leave fieldCategories undefined or
6584
6819
  // non-array; both consumers below iterate it, so coerce to a safe array.
6585
6820
  const fieldCategories = Array.isArray(result.fieldCategories) ? result.fieldCategories : [];
6586
- await this.applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories);
6821
+ await this.applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories, { isNewEntity });
6587
6822
  // Auto-detect geo-capable entities and set SupportsGeoCoding
6588
- await this.detectAndSetGeoCodingSupport(pool, entity, fieldCategories);
6823
+ await this.detectAndSetGeoCodingSupport(pool, entity);
6589
6824
  if (result.entityIcon) {
6590
- await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
6825
+ await this.applyEntityIcon(pool, entity.ID, result.entityIcon, entity.Name);
6591
6826
  }
6592
6827
  // Resolve categoryInfo from new or legacy format
6593
6828
  const categoryInfoToStore = result.categoryInfo ||
6594
6829
  (result.categoryIcons ?
6595
6830
  Object.fromEntries(Object.entries(result.categoryIcons).map(([cat, icon]) => [cat, { icon, description: '' }])) : null);
6596
6831
  if (categoryInfoToStore) {
6597
- await this.applyCategoryInfoSettings(pool, entity.ID, categoryInfoToStore);
6832
+ await this.applyCategoryInfoSettings(pool, entity.ID, categoryInfoToStore, entity.Name);
6598
6833
  }
6599
6834
  if (isNewEntity && result.entityImportance) {
6600
- await this.applyEntityImportance(pool, entity.ID, result.entityImportance);
6835
+ await this.applyEntityImportance(pool, entity.ID, result.entityImportance, entity.Name);
6601
6836
  }
6602
6837
  }
6603
6838
  /**
6604
- * Detect if an entity has geo-capable fields based on LLM-assigned ExtendedType values.
6839
+ * Detect if an entity has geo-capable fields based on persisted ExtendedType values in the database.
6605
6840
  * If any field has a Geo* ExtendedType, set Entity.SupportsGeoCoding = 1.
6606
6841
  * Respects the AutoUpdateSupportsGeoCoding flag — if 0, the value is locked.
6607
6842
  */
6608
- async detectAndSetGeoCodingSupport(pool, entity, fieldCategories) {
6843
+ async detectAndSetGeoCodingSupport(pool, entity) {
6609
6844
  const schema = mj_core_schema();
6610
6845
  // Check if the entity's AutoUpdateSupportsGeoCoding flag allows us to modify it
6611
6846
  const autoUpdateResult = await pool.query(`
@@ -6617,14 +6852,8 @@ export class ManageMetadataBase {
6617
6852
  if (!row || !row.AutoUpdateSupportsGeoCoding) {
6618
6853
  return; // Flag is locked, don't modify
6619
6854
  }
6620
- // Detect geo-capable fields from the LLM results
6621
- const geoExtendedTypes = new Set([
6622
- 'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
6623
- 'GeoCity', 'GeoPostalCode', 'GeoAddress'
6624
- ]);
6625
- const hasGeoFields = fieldCategories.some(fc => fc.extendedType != null && geoExtendedTypes.has(fc.extendedType));
6626
- // Also check existing fields in the database for Geo* ExtendedTypes
6627
- // (in case the LLM didn't re-assign them this run but they were previously set)
6855
+ // Check existing fields in the database for Geo* ExtendedTypes
6856
+ // (relies on persisted state after applyFieldCategories has run)
6628
6857
  const existingGeoResult = await pool.query(`
6629
6858
  SELECT COUNT(*) AS ${this.qi('GeoFieldCount')}
6630
6859
  FROM ${this.qs(schema, 'EntityField')}
@@ -6632,7 +6861,7 @@ export class ManageMetadataBase {
6632
6861
  AND ${this.qi('ExtendedType')} IN ('Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince', 'GeoCity', 'GeoPostalCode', 'GeoAddress')
6633
6862
  `);
6634
6863
  const existingGeoCount = existingGeoResult?.recordset?.[0]?.GeoFieldCount ?? 0;
6635
- const shouldSupportGeo = hasGeoFields || existingGeoCount > 0;
6864
+ const shouldSupportGeo = existingGeoCount > 0;
6636
6865
  const currentValue = row.SupportsGeoCoding ? true : false;
6637
6866
  if (shouldSupportGeo !== currentValue) {
6638
6867
  await this.LogSQLAndExecute(pool, `
@@ -6640,7 +6869,7 @@ export class ManageMetadataBase {
6640
6869
  SET ${this.qi('SupportsGeoCoding')} = ${this.boolLit(shouldSupportGeo)}
6641
6870
  WHERE ${this.qi('ID')} = '${entity.ID}' AND ${this.qi('AutoUpdateSupportsGeoCoding')} = ${this.boolLit(true)}
6642
6871
  `, `Set SupportsGeoCoding = ${shouldSupportGeo} for ${entity.Name}`);
6643
- logStatus(` Entity ${entity.Name}: SupportsGeoCoding = ${shouldSupportGeo ? 1 : 0} (auto-detected from ${hasGeoFields ? 'LLM' : 'existing'} geo fields)`);
6872
+ logStatus(` Entity ${entity.Name}: SupportsGeoCoding = ${shouldSupportGeo ? 1 : 0} (auto-detected from persisted geo fields)`);
6644
6873
  // Queue for late-phase view regeneration — the view was already generated
6645
6874
  // before this flag was set, so it needs to be regenerated with the geo JOIN
6646
6875
  ManageMetadataBase.AddEntityRequiringViewRegen(entity.Name, 'Geocoding');
@@ -6687,55 +6916,67 @@ export class ManageMetadataBase {
6687
6916
  * Enforces stability rules: fields with existing categories cannot move to NEW categories.
6688
6917
  * All SQL updates are batched into a single execution for performance.
6689
6918
  */
6690
- async applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories) {
6919
+ async applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories, ctx) {
6691
6920
  const sqlStatements = [];
6921
+ const reporter = CodeGenReporter.Instance;
6922
+ // Count fields with blank Category that the LLM omitted (FM2 re-trigger loop tracking)
6923
+ const proposedNames = new Set(fieldCategories.map(fc => (fc.fieldName ?? '').trim().toLowerCase()));
6924
+ let omittedBlankCount = 0;
6925
+ for (const field of fields) {
6926
+ const isBlankCategory = !field.Category || field.Category.trim() === '';
6927
+ if (isBlankCategory && field.AutoUpdateCategory && !proposedNames.has(field.Name.trim().toLowerCase())) {
6928
+ omittedBlankCount++;
6929
+ }
6930
+ }
6931
+ if (omittedBlankCount > 0) {
6932
+ reporter.counter('ai.fieldsOmittedByLLM', omittedBlankCount);
6933
+ }
6692
6934
  for (const fieldCategory of fieldCategories) {
6693
- const field = fields.find(f => f.Name === fieldCategory.fieldName);
6694
- if (field && field.AutoUpdateCategory && field.ID) {
6695
- // Override category to "System Metadata" for __mj_ fields (system audit fields)
6696
- let category = fieldCategory.category;
6697
- if (field.Name.startsWith('__mj_')) {
6698
- category = 'System Metadata';
6699
- }
6700
- // ENFORCEMENT: Prevent category renaming
6701
- const fieldHasExistingCategory = field.Category != null && field.Category.trim() !== '';
6702
- const categoryIsNew = !existingCategories.has(category);
6703
- if (fieldHasExistingCategory && categoryIsNew) {
6704
- logStatus(` Rejected category change for field '${field.Name}': cannot move from existing category '${field.Category}' to new category '${category}'. Keeping original category.`);
6705
- category = field.Category;
6706
- }
6707
- const setClauses = [];
6708
- if (field.Category !== category) {
6709
- setClauses.push(`Category = '${category.replace(/'/g, "''")}'`);
6710
- }
6711
- if (field.GeneratedFormSection !== 'Category') {
6712
- setClauses.push(`GeneratedFormSection = 'Category'`);
6713
- }
6714
- if (fieldCategory.displayName && field.AutoUpdateDisplayName && field.DisplayName !== fieldCategory.displayName) {
6715
- setClauses.push(`DisplayName = '${fieldCategory.displayName.replace(/'/g, "''")}'`);
6716
- }
6717
- if (fieldCategory.extendedType !== undefined && field.ExtendedType !== fieldCategory.extendedType) {
6718
- const extendedType = fieldCategory.extendedType === null ? 'NULL' : `'${String(fieldCategory.extendedType).replace(/'/g, "''")}'`;
6719
- setClauses.push(`ExtendedType = ${extendedType}`);
6935
+ const field = this.findFieldByName(fields, fieldCategory.fieldName);
6936
+ if (!field) {
6937
+ logError(`Form layout returned invalid fieldName: '${fieldCategory.fieldName}' not found in entity`);
6938
+ continue;
6939
+ }
6940
+ const lockCtx = {
6941
+ isNewEntity: ctx.isNewEntity,
6942
+ isNewField: ManageMetadataBase.isFieldNew(entity.ID, field.Name),
6943
+ descriptionReopened: ManageMetadataBase.isDisplayNameReopened(entity.ID, field.Name),
6944
+ typeReopened: ManageMetadataBase.isTypeReopened(entity.ID, field.Name),
6945
+ existingCategories,
6946
+ };
6947
+ const update = computeFieldMetadataUpdate(field, fieldCategory, lockCtx, this.validateExtendedType.bind(this), (val, fn) => this.sanitizeCodeType(val, fn ?? field.Name, entity.Name));
6948
+ for (const skip of update.skipped) {
6949
+ if (skip.reason === 'locked') {
6950
+ reporter.counter('ai.fieldsLocked');
6720
6951
  }
6721
- if (fieldCategory.codeType !== undefined) {
6722
- const sanitized = this.sanitizeCodeType(fieldCategory.codeType, field.Name, entity.Name);
6723
- if (field.CodeType !== sanitized) {
6724
- const codeType = sanitized == null ? 'NULL' : `'${sanitized.replace(/'/g, "''")}'`;
6725
- setClauses.push(`CodeType = ${codeType}`);
6726
- }
6952
+ else if (skip.reason === 'invalid') {
6953
+ reporter.counter('ai.fieldsInvalidProposal');
6727
6954
  }
6728
- if (setClauses.length > 0) {
6729
- // only generate an UPDATE if we have 1+ set clause
6730
- sqlStatements.push(`\n-- UPDATE Entity Field Category Info ${entity.Name}.${field.Name} \nUPDATE ${this.qs(mj_core_schema(), 'EntityField')}
6955
+ }
6956
+ const setClauses = [];
6957
+ if (update.Category !== undefined) {
6958
+ setClauses.push(`Category = '${update.Category.replace(/'/g, "''")}'`);
6959
+ }
6960
+ if (update.GeneratedFormSection !== undefined) {
6961
+ setClauses.push(`GeneratedFormSection = 'Category'`);
6962
+ }
6963
+ if (update.DisplayName !== undefined) {
6964
+ setClauses.push(`DisplayName = '${update.DisplayName.replace(/'/g, "''")}'`);
6965
+ }
6966
+ if (update.ExtendedType !== undefined) {
6967
+ const extVal = update.ExtendedType === null ? 'NULL' : `'${update.ExtendedType.replace(/'/g, "''")}'`;
6968
+ setClauses.push(`ExtendedType = ${extVal}`);
6969
+ }
6970
+ if (update.CodeType !== undefined) {
6971
+ const codeVal = update.CodeType === null ? 'NULL' : `'${update.CodeType.replace(/'/g, "''")}'`;
6972
+ setClauses.push(`CodeType = ${codeVal}`);
6973
+ }
6974
+ if (setClauses.length > 0) {
6975
+ sqlStatements.push(`\n-- UPDATE Entity Field Category Info ${entity.Name}.${field.Name} \nUPDATE ${this.qs(mj_core_schema(), 'EntityField')}
6731
6976
  SET
6732
6977
  ${setClauses.join(',\n ')}
6733
6978
  WHERE
6734
- ID = '${field.ID}' AND AutoUpdateCategory = ${this.boolLit(true)}`);
6735
- }
6736
- }
6737
- else if (!field) {
6738
- logError(`Form layout returned invalid fieldName: '${fieldCategory.fieldName}' not found in entity`);
6979
+ ID = '${field.ID}'`);
6739
6980
  }
6740
6981
  }
6741
6982
  if (sqlStatements.length > 0) {
@@ -6750,7 +6991,7 @@ WHERE
6750
6991
  /**
6751
6992
  * Sets the entity icon if the entity doesn't already have one.
6752
6993
  */
6753
- async applyEntityIcon(pool, entityId, entityIcon) {
6994
+ async applyEntityIcon(pool, entityId, entityIcon, entityName) {
6754
6995
  if (!entityIcon || entityIcon.trim().length === 0)
6755
6996
  return;
6756
6997
  const checkSQL = `SELECT Icon FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE ID = '${entityId}'`;
@@ -6777,32 +7018,46 @@ WHERE
6777
7018
  /**
6778
7019
  * Upserts FieldCategoryInfo (new format) and FieldCategoryIcons (legacy format) in EntitySetting.
6779
7020
  */
6780
- async applyCategoryInfoSettings(pool, entityId, categoryInfo) {
7021
+ async applyCategoryInfoSettings(pool, entityId, categoryInfo, _entityName) {
6781
7022
  if (!categoryInfo || Object.keys(categoryInfo).length === 0)
6782
7023
  return;
6783
- const infoJSON = JSON.stringify(categoryInfo).replace(/'/g, "''");
7024
+ const canonicalInfo = canonicalJSONStringify(categoryInfo, 2);
7025
+ const infoJSON = canonicalInfo.replace(/'/g, "''");
7026
+ const ENTITY_SETTING_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
6784
7027
  // Upsert FieldCategoryInfo (new format)
6785
- const checkNewSQL = `SELECT ${this.qi('ID')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
7028
+ const checkNewSQL = `SELECT ${this.qi('ID')}, ${this.qi('Value')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
6786
7029
  const existingNew = await this.runQuery(pool, checkNewSQL);
6787
7030
  if (existingNew.recordset.length > 0) {
7031
+ let isMatch = false;
6788
7032
  try {
6789
- await this.LogSQLAndExecute(pool, `
6790
- UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
6791
- SET ${this.qi('Value')} = '${infoJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
6792
- WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
6793
- `, `Update FieldCategoryInfo setting for entity`, false);
7033
+ const rawVal = existingNew.recordset[0].Value;
7034
+ const parsed = typeof rawVal === 'string' ? JSON.parse(rawVal) : rawVal;
7035
+ isMatch = deepEqualJSON(parsed, categoryInfo);
6794
7036
  }
6795
- catch (ex) {
6796
- logError('Error Applying Category Info Settings: Part 1', ex);
7037
+ catch {
7038
+ isMatch = false;
7039
+ }
7040
+ if (!isMatch) {
7041
+ try {
7042
+ await this.LogSQLAndExecute(pool, `
7043
+ UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
7044
+ SET ${this.qi('Value')} = '${infoJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
7045
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
7046
+ `, `Update FieldCategoryInfo setting for entity`, false);
7047
+ }
7048
+ catch (ex) {
7049
+ logError('Error Applying Category Info Settings: Part 1', ex);
7050
+ }
6797
7051
  }
6798
7052
  }
6799
7053
  else {
6800
- const newId = uuidv4();
7054
+ const newId = uuidv5(`${entityId}|FieldCategoryInfo`, ENTITY_SETTING_NAMESPACE);
6801
7055
  try {
6802
- await this.LogSQLAndExecute(pool, `
6803
- INSERT INTO ${this.qs(mj_core_schema(), 'EntitySetting')} (${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Name')}, ${this.qi('Value')}, ${this.qi(EntityInfo.CreatedAtFieldName)}, ${this.qi(EntityInfo.UpdatedAtFieldName)})
6804
- VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})
6805
- `, `Insert FieldCategoryInfo setting for entity`, false);
7056
+ const checkQuery = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
7057
+ 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)})
7058
+ VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})`;
7059
+ const sSQL = this.conditionalInsert(checkQuery, insertSQL);
7060
+ await this.LogSQLAndExecute(pool, sSQL, `Insert FieldCategoryInfo setting for entity`, false);
6806
7061
  }
6807
7062
  catch (ex) {
6808
7063
  logError('Error Applying Category Info Settings: Part 2', ex);
@@ -6815,28 +7070,41 @@ WHERE
6815
7070
  iconsOnly[category] = info.icon;
6816
7071
  }
6817
7072
  }
6818
- const iconsJSON = JSON.stringify(iconsOnly).replace(/'/g, "''");
6819
- const checkLegacySQL = `SELECT ${this.qi('ID')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
7073
+ const canonicalIcons = canonicalJSONStringify(iconsOnly, 2);
7074
+ const iconsJSON = canonicalIcons.replace(/'/g, "''");
7075
+ const checkLegacySQL = `SELECT ${this.qi('ID')}, ${this.qi('Value')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
6820
7076
  const existingLegacy = await this.runQuery(pool, checkLegacySQL);
6821
7077
  if (existingLegacy.recordset.length > 0) {
7078
+ let isMatch = false;
6822
7079
  try {
6823
- await this.LogSQLAndExecute(pool, `
6824
- UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
6825
- SET ${this.qi('Value')} = '${iconsJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
6826
- WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
6827
- `, `Update FieldCategoryIcons setting (legacy)`, false);
7080
+ const rawVal = existingLegacy.recordset[0].Value;
7081
+ const parsed = typeof rawVal === 'string' ? JSON.parse(rawVal) : rawVal;
7082
+ isMatch = deepEqualJSON(parsed, iconsOnly);
6828
7083
  }
6829
- catch (ex) {
6830
- logError('Error Applying Category Info Settings: Part 3', ex);
7084
+ catch {
7085
+ isMatch = false;
7086
+ }
7087
+ if (!isMatch) {
7088
+ try {
7089
+ await this.LogSQLAndExecute(pool, `
7090
+ UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
7091
+ SET ${this.qi('Value')} = '${iconsJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
7092
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
7093
+ `, `Update FieldCategoryIcons setting (legacy)`, false);
7094
+ }
7095
+ catch (ex) {
7096
+ logError('Error Applying Category Info Settings: Part 3', ex);
7097
+ }
6831
7098
  }
6832
7099
  }
6833
7100
  else {
6834
- const newId = uuidv4();
7101
+ const newId = uuidv5(`${entityId}|FieldCategoryIcons`, ENTITY_SETTING_NAMESPACE);
6835
7102
  try {
6836
- await this.LogSQLAndExecute(pool, `
6837
- INSERT INTO ${this.qs(mj_core_schema(), 'EntitySetting')} (${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Name')}, ${this.qi('Value')}, ${this.qi(EntityInfo.CreatedAtFieldName)}, ${this.qi(EntityInfo.UpdatedAtFieldName)})
6838
- VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})
6839
- `, `Insert FieldCategoryIcons setting (legacy)`, false);
7103
+ const checkQuery = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
7104
+ 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)})
7105
+ VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})`;
7106
+ const sSQL = this.conditionalInsert(checkQuery, insertSQL);
7107
+ await this.LogSQLAndExecute(pool, sSQL, `Insert FieldCategoryIcons setting (legacy)`, false);
6840
7108
  }
6841
7109
  catch (ex) {
6842
7110
  logError('Error Applying Category Info Settings: Part 4', ex);
@@ -6847,7 +7115,7 @@ WHERE
6847
7115
  * Applies entity importance analysis to MJApplicationEntity records.
6848
7116
  * Only called for NEW entities to set DefaultForNewUser.
6849
7117
  */
6850
- async applyEntityImportance(pool, entityId, importance) {
7118
+ async applyEntityImportance(pool, entityId, importance, entityName) {
6851
7119
  const updateSQL = `
6852
7120
  UPDATE ${this.qs(mj_core_schema(), 'ApplicationEntity')}
6853
7121
  SET ${this.qi('DefaultForNewUser')} = ${this.boolLit(importance.defaultForNewUser)}, ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}