@memberjunction/core 6.2.0-edge.0 → 6.2.0-edge.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +14 -0
  2. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -1
  3. package/dist/generic/QueryCache.d.ts +17 -0
  4. package/dist/generic/QueryCache.d.ts.map +1 -1
  5. package/dist/generic/QueryCache.js +25 -5
  6. package/dist/generic/QueryCache.js.map +1 -1
  7. package/dist/generic/RegisterForStartup.d.ts +5 -5
  8. package/dist/generic/RegisterForStartup.js +12 -12
  9. package/dist/generic/applicationInfo.d.ts +4 -2
  10. package/dist/generic/applicationInfo.d.ts.map +1 -1
  11. package/dist/generic/applicationInfo.js +13 -9
  12. package/dist/generic/applicationInfo.js.map +1 -1
  13. package/dist/generic/baseEngine.d.ts +9 -1
  14. package/dist/generic/baseEngine.d.ts.map +1 -1
  15. package/dist/generic/baseEngine.js +13 -3
  16. package/dist/generic/baseEngine.js.map +1 -1
  17. package/dist/generic/baseEngineRegistry.d.ts +22 -9
  18. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  19. package/dist/generic/baseEngineRegistry.js +43 -25
  20. package/dist/generic/baseEngineRegistry.js.map +1 -1
  21. package/dist/generic/baseEntity.d.ts +221 -17
  22. package/dist/generic/baseEntity.d.ts.map +1 -1
  23. package/dist/generic/baseEntity.js +538 -113
  24. package/dist/generic/baseEntity.js.map +1 -1
  25. package/dist/generic/baseInfo.d.ts +12 -0
  26. package/dist/generic/baseInfo.d.ts.map +1 -1
  27. package/dist/generic/baseInfo.js +22 -1
  28. package/dist/generic/baseInfo.js.map +1 -1
  29. package/dist/generic/column-descriptors.d.ts +25 -5
  30. package/dist/generic/column-descriptors.d.ts.map +1 -1
  31. package/dist/generic/column-descriptors.js +45 -5
  32. package/dist/generic/column-descriptors.js.map +1 -1
  33. package/dist/generic/compositeKey.d.ts +1 -1
  34. package/dist/generic/compositeKey.js +2 -2
  35. package/dist/generic/data-table.js +2 -2
  36. package/dist/generic/data-table.js.map +1 -1
  37. package/dist/generic/databaseProviderBase.js +2 -2
  38. package/dist/generic/entityInfo.d.ts +171 -38
  39. package/dist/generic/entityInfo.d.ts.map +1 -1
  40. package/dist/generic/entityInfo.js +314 -78
  41. package/dist/generic/entityInfo.js.map +1 -1
  42. package/dist/generic/entitySubtypeResolver.d.ts +51 -5
  43. package/dist/generic/entitySubtypeResolver.d.ts.map +1 -1
  44. package/dist/generic/entitySubtypeResolver.js +50 -3
  45. package/dist/generic/entitySubtypeResolver.js.map +1 -1
  46. package/dist/generic/filters/compositeFilter.d.ts +19 -19
  47. package/dist/generic/filters/compositeFilter.js +60 -60
  48. package/dist/generic/graphqlTypeNames.d.ts +6 -0
  49. package/dist/generic/graphqlTypeNames.d.ts.map +1 -1
  50. package/dist/generic/graphqlTypeNames.js +18 -6
  51. package/dist/generic/graphqlTypeNames.js.map +1 -1
  52. package/dist/generic/interfaces.d.ts +1 -1
  53. package/dist/generic/interfaces.js +3 -3
  54. package/dist/generic/logging.d.ts +2 -0
  55. package/dist/generic/logging.d.ts.map +1 -1
  56. package/dist/generic/logging.js +7 -3
  57. package/dist/generic/logging.js.map +1 -1
  58. package/dist/generic/metadata.d.ts +1 -1
  59. package/dist/generic/metadata.js +3 -3
  60. package/dist/generic/providerBase.d.ts +7 -7
  61. package/dist/generic/providerBase.d.ts.map +1 -1
  62. package/dist/generic/providerBase.js +26 -26
  63. package/dist/generic/providerBase.js.map +1 -1
  64. package/dist/generic/queryResultEnricher.d.ts +2 -0
  65. package/dist/generic/queryResultEnricher.d.ts.map +1 -1
  66. package/dist/generic/queryResultEnricher.js +5 -1
  67. package/dist/generic/queryResultEnricher.js.map +1 -1
  68. package/dist/generic/querySQLFilters.d.ts +4 -0
  69. package/dist/generic/querySQLFilters.d.ts.map +1 -1
  70. package/dist/generic/querySQLFilters.js +10 -2
  71. package/dist/generic/querySQLFilters.js.map +1 -1
  72. package/dist/generic/relatedRecordCollection.d.ts +2 -0
  73. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  74. package/dist/generic/relatedRecordCollection.js +5 -1
  75. package/dist/generic/relatedRecordCollection.js.map +1 -1
  76. package/dist/generic/remoteEventRow.d.ts +55 -0
  77. package/dist/generic/remoteEventRow.d.ts.map +1 -0
  78. package/dist/generic/remoteEventRow.js +110 -0
  79. package/dist/generic/remoteEventRow.js.map +1 -0
  80. package/dist/generic/remoteOperationDispatch.d.ts +2 -0
  81. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  82. package/dist/generic/remoteOperationDispatch.js +5 -1
  83. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  84. package/dist/generic/runQuerySQLFilterImplementations.d.ts +8 -0
  85. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  86. package/dist/generic/runQuerySQLFilterImplementations.js +20 -4
  87. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  88. package/dist/generic/schemaHydration.d.ts +8 -0
  89. package/dist/generic/schemaHydration.d.ts.map +1 -1
  90. package/dist/generic/schemaHydration.js +21 -5
  91. package/dist/generic/schemaHydration.js.map +1 -1
  92. package/dist/generic/securityInfo.d.ts +11 -9
  93. package/dist/generic/securityInfo.d.ts.map +1 -1
  94. package/dist/generic/securityInfo.js +31 -27
  95. package/dist/generic/securityInfo.js.map +1 -1
  96. package/dist/generic/telemetryManager.d.ts +16 -0
  97. package/dist/generic/telemetryManager.d.ts.map +1 -1
  98. package/dist/generic/telemetryManager.js +52 -20
  99. package/dist/generic/telemetryManager.js.map +1 -1
  100. package/dist/generic/transactionGroup.d.ts +1 -1
  101. package/dist/generic/transactionGroup.js +4 -4
  102. package/dist/generic/transactionGroup.js.map +1 -1
  103. package/dist/index.d.ts +1 -0
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +1 -0
  106. package/dist/index.js.map +1 -1
  107. package/dist/views/viewInfo.d.ts +2 -2
  108. package/dist/views/viewInfo.js +13 -13
  109. package/dist/views/viewInfo.js.map +1 -1
  110. package/package.json +3 -3
@@ -3,7 +3,7 @@ import { Metadata } from "./metadata.js";
3
3
  import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, FormatValue, CodeNameFromString } from "./util.js";
4
4
  import { IsFixedWidthStringSQLType } from "@memberjunction/sql-dialect";
5
5
  import { LogError } from "./logging.js";
6
- import { WarningManager, SafeJSONParse, UUIDsEqual, ordinalCompare } from "@memberjunction/global";
6
+ import { WarningManager, SafeJSONParse, UUIDsEqual, ordinalCompare, GeneratePluralName } from "@memberjunction/global";
7
7
  import { ReadRelationshipJoinFields, } from "./entityConfiguration.js";
8
8
  /**
9
9
  * Runtime domain for {@link EntityFieldInfo.ExtendedType}. This array is the single source of
@@ -216,7 +216,7 @@ export class EntityOrganicKeyInfo extends BaseInfo {
216
216
  this.__mj_UpdatedAt = null;
217
217
  // virtual fields from the database view
218
218
  this.Entity = null;
219
- this._RelatedEntities = [];
219
+ this._RelatedEntities = []; // case-violation-ok-legacy-back-compat: a class in the same hierarchy already declares the camelCase name — TypeScript rejects two declarations of one private property (TS2415)
220
220
  if (initData) {
221
221
  this.copyInitData(initData);
222
222
  this._RelatedEntities = [];
@@ -686,6 +686,38 @@ export class EntityFieldInfo extends BaseInfo {
686
686
  get HierarchyMaxDepth() {
687
687
  return this.ConfigurationObject?.Hierarchy?.MaxDepth ?? 100;
688
688
  }
689
+ /** @deprecated Use {@link RelatedEntityTableAlias}. */
690
+ get _RelatedEntityTableAlias() {
691
+ return this.RelatedEntityTableAlias;
692
+ }
693
+ /** @deprecated Use {@link RelatedEntityTableAlias}. */
694
+ set _RelatedEntityTableAlias(value) {
695
+ this.RelatedEntityTableAlias = value;
696
+ }
697
+ /** @deprecated Use {@link RelatedEntityNameFieldIsVirtual}. */
698
+ get _RelatedEntityNameFieldIsVirtual() {
699
+ return this.RelatedEntityNameFieldIsVirtual;
700
+ }
701
+ /** @deprecated Use {@link RelatedEntityNameFieldIsVirtual}. */
702
+ set _RelatedEntityNameFieldIsVirtual(value) {
703
+ this.RelatedEntityNameFieldIsVirtual = value;
704
+ }
705
+ /** @deprecated Use {@link RelatedEntityNameFieldIsComputed}. */
706
+ get _RelatedEntityNameFieldIsComputed() {
707
+ return this.RelatedEntityNameFieldIsComputed;
708
+ }
709
+ /** @deprecated Use {@link RelatedEntityNameFieldIsComputed}. */
710
+ set _RelatedEntityNameFieldIsComputed(value) {
711
+ this.RelatedEntityNameFieldIsComputed = value;
712
+ }
713
+ /** @deprecated Use {@link RelatedEntityJoinFieldMappings}. */
714
+ get _RelatedEntityJoinFieldMappings() {
715
+ return this.RelatedEntityJoinFieldMappings;
716
+ }
717
+ /** @deprecated Use {@link RelatedEntityJoinFieldMappings}. */
718
+ set _RelatedEntityJoinFieldMappings(value) {
719
+ this.RelatedEntityJoinFieldMappings = value;
720
+ }
689
721
  /**
690
722
  * JSON configuration for additional fields to join from the related entity.
691
723
  * Parsed from the RelatedEntityJoinFields column. Uses lazy initialization and caching
@@ -728,7 +760,7 @@ export class EntityFieldInfo extends BaseInfo {
728
760
  * Empty for the overwhelming majority of fields — see {@link HasFieldPermissions}.
729
761
  */
730
762
  get FieldPermissions() {
731
- return this._FieldPermissions;
763
+ return this._fieldPermissions;
732
764
  }
733
765
  /**
734
766
  * True when at least one {@link EntityFieldPermissionInfo} record exists for this field.
@@ -739,7 +771,7 @@ export class EntityFieldInfo extends BaseInfo {
739
771
  * enabled entity a field with no records is denied, not open.
740
772
  */
741
773
  get HasFieldPermissions() {
742
- return this._FieldPermissions.length > 0;
774
+ return this._fieldPermissions.length > 0;
743
775
  }
744
776
  /**
745
777
  * Entities whose fields can never be restricted by field-level security.
@@ -760,7 +792,7 @@ export class EntityFieldInfo extends BaseInfo {
760
792
  *
761
793
  * Stored lowercased; compare with a trimmed, lowercased entity name.
762
794
  */
763
- static { this.UnrestrictableEntityNames = new Set([
795
+ static { this.unrestrictableEntityNames = new Set([
764
796
  'mj: entities',
765
797
  'mj: entity fields',
766
798
  'mj: entity permissions',
@@ -774,7 +806,7 @@ export class EntityFieldInfo extends BaseInfo {
774
806
  * See {@link EntityFieldInfo.UnrestrictableEntityNames} for the rationale.
775
807
  */
776
808
  get IsOnUnrestrictableEntity() {
777
- return EntityFieldInfo.UnrestrictableEntityNames.has((this.Entity ?? '').trim().toLowerCase());
809
+ return EntityFieldInfo.unrestrictableEntityNames.has((this.Entity ?? '').trim().toLowerCase());
778
810
  }
779
811
  /**
780
812
  * True for fields that must remain readable regardless of any permission record:
@@ -859,7 +891,7 @@ export class EntityFieldInfo extends BaseInfo {
859
891
  * arithmetic.
860
892
  */
861
893
  aggregateUserFieldPermissions(user) {
862
- return EntityFieldInfo.AggregateFieldRulesForUser(this._FieldPermissions, user);
894
+ return EntityFieldInfo.AggregateFieldRulesForUser(this._fieldPermissions, user);
863
895
  }
864
896
  /**
865
897
  * The same aggregation {@link GetUserFieldPermissions} performs, over a rule list the caller
@@ -958,8 +990,9 @@ export class EntityFieldInfo extends BaseInfo {
958
990
  * * **Stringifying is required.** `EntityFieldValue.Value` is always a string in metadata while
959
991
  * the field's runtime value may be a number, so a strict `===` would reject every legal value
960
992
  * on a numeric list. It is not lossless: `String(1.0)` is `'1'`, so a metadata value written
961
- * as `'1.0'` would fail closed. No numeric value lists exist today (CodeGen cannot produce
962
- * one — see the note in ValueIsPermittedByValueList), so this is recorded rather than solved.
993
+ * as `'1.00'` would fail closed here — which is why a NUMERIC column's list is not compared
994
+ * through this function at all but by value, in numericValueIsPermittedByValueList (MJ #3978).
995
+ * This normalization is what a STRING column's list is compared with.
963
996
  * * **Trimming is cheap insurance, NOT the load-bearing rule it was first documented as.** An
964
997
  * earlier version of this comment claimed an untrimmed comparison would reject 9,301 existing
965
998
  * rows in fixed-width `nchar` columns (`MJ: Action Params`.Type, `MJ: Record Changes`.Status
@@ -1002,10 +1035,10 @@ export class EntityFieldInfo extends BaseInfo {
1002
1035
  * * Only string and number values are checked, and the gate fails OPEN — an unsupported type
1003
1036
  * skips validation rather than manufacturing a failure. Nothing in the schema restricts
1004
1037
  * which columns may carry a value list (`CK_EntityField_ValueListType_New` constrains the
1005
- * mode, not the column type), but in practice every one is a string column: measured on a
1006
- * current 6.x instance, 455 nvarchar + 7 nchar and nothing else, which follows from
1007
- * CodeGen's constraint parser only ever extracting quoted literals. `number` is admitted
1008
- * because the generated union type anticipates a non-quoted list via `NeedsQuotes`.
1038
+ * mode, not the column type). Every list WAS on a string column — measured on a 6.x
1039
+ * instance, 455 nvarchar + 7 nchar and nothing else — because CodeGen's constraint parser
1040
+ * only extracted quoted literals; since MJ #3978 it extracts the unquoted numeric form too,
1041
+ * so a numeric column's list is now a real case and is compared by value (below).
1009
1042
  * Booleans and Dates are excluded deliberately: a bit column carrying a `'1'`/`'0'` list
1010
1043
  * would see `String(true) === 'true'` and reject every legal value, and a Date has no sane
1011
1044
  * string form to compare — so guessing there would break saves rather than guard them.
@@ -1059,17 +1092,72 @@ export class EntityFieldInfo extends BaseInfo {
1059
1092
  if (typeof value !== 'string' && typeof value !== 'number') {
1060
1093
  // The rule cannot be applied to this type at all, and that is a mismatch rather than a
1061
1094
  // state to absorb: either the field should not declare a value list (one on a bit column
1062
- // — which CodeGen never produces, since SQL Server renders `IN (0,1)` as unquoted
1063
- // `([B]=(1) OR [B]=(0))`, the same reason no NUMERIC list exists either; see MJ #3978)
1064
- // or a caller assigned the wrong type. Skipping it silently would
1095
+ // — which CodeGen still never produces: it captures the unquoted numeric form as of MJ
1096
+ // #3978, but excludes `bit` fields, since `IN (0,1)` is vacuous on a bit and `= 1` is a
1097
+ // validator rather than a dropdown) or a caller assigned the wrong type. Skipping it silently would
1065
1098
  // leave the caller believing a guard is on when it is not, which is the exact failure
1066
1099
  // mode this rung was added to fix — so it is reported, once per field.
1067
1100
  this.reportUnsupportedValueListValue(`a ${typeof value} value (SQL type ${this.SQLFullType})`, 'value-list validation compares strings, numbers and dates only — comparing anything ' +
1068
1101
  'else would reject legal values rather than guard them');
1069
1102
  return true;
1070
1103
  }
1104
+ // A NUMERIC column's list is compared by VALUE, not by string form — see
1105
+ // numericValueIsPermittedByValueList. Falls through to the string comparison when the list is
1106
+ // not entirely numeric, which is the only case where there is nothing to compare against.
1107
+ if (this.TSType === EntityFieldTSType.Number) {
1108
+ const numericKeys = this.valueListNumericKeys();
1109
+ if (numericKeys) {
1110
+ return this.numericValueIsPermittedByValueList(value, numericKeys);
1111
+ }
1112
+ }
1071
1113
  return this._normalizedValueListValues.has(EntityFieldInfo.NormalizeValueListValue(value));
1072
1114
  }
1115
+ /**
1116
+ * Value-list membership for a NUMERIC column, compared on the numeric VALUE rather than the
1117
+ * string form (MJ issue #3978).
1118
+ *
1119
+ * Necessary because the two sides are written by different hands: SQL Server stores
1120
+ * `CHECK (Price IN (0.50, 1.00))` as the unquoted literals `([Price]=(1.00) OR [Price]=(0.50))`,
1121
+ * CodeGen captures those literals verbatim, and the runtime value of that column is the number
1122
+ * `1`. `String(1) === '1'` is not `'1.00'`, so a string comparison would refuse a value the
1123
+ * database accepts — the exact failure mode this rung exists to avoid. Comparing as numbers makes
1124
+ * the scale of the literal irrelevant.
1125
+ *
1126
+ * The comparison is gated on the column being numeric, NOT merely on the list looking numeric: on
1127
+ * a string column `CHECK (Code IN ('1','2'))` really is a string list, and comparing `'01'`
1128
+ * numerically would permit a value the database refuses.
1129
+ *
1130
+ * A value that is not a finite number falls back to the string comparison rather than being
1131
+ * refused here, so a value list that happens to hold non-numeric text on a numeric column (which
1132
+ * CodeGen cannot produce, but `applyValueListConfig` could) still behaves as it did before.
1133
+ */
1134
+ numericValueIsPermittedByValueList(value, numericKeys) {
1135
+ const candidate = typeof value === 'number' ? value : Number(value.trim());
1136
+ if (!Number.isFinite(candidate)) {
1137
+ return this._normalizedValueListValues.has(EntityFieldInfo.NormalizeValueListValue(value));
1138
+ }
1139
+ return numericKeys.has(candidate);
1140
+ }
1141
+ /**
1142
+ * The value list as a set of numbers, or null when any of its values is not a finite number (so
1143
+ * there is nothing to compare numerically). Memoized alongside the normalized string set, and
1144
+ * built from it — lower-casing and trimming cannot change how a numeric literal parses.
1145
+ */
1146
+ valueListNumericKeys() {
1147
+ if (this._valueListNumericKeys === undefined) {
1148
+ const keys = new Set();
1149
+ for (const normalized of this._normalizedValueListValues ?? []) {
1150
+ const parsed = Number(normalized);
1151
+ if (normalized.length === 0 || !Number.isFinite(parsed)) {
1152
+ this._valueListNumericKeys = null;
1153
+ return null;
1154
+ }
1155
+ keys.add(parsed);
1156
+ }
1157
+ this._valueListNumericKeys = keys.size > 0 ? keys : null;
1158
+ }
1159
+ return this._valueListNumericKeys;
1160
+ }
1073
1161
  /**
1074
1162
  * Value-list membership for a `Date` runtime value, compared on the CALENDAR DATE rather than
1075
1163
  * the instant.
@@ -1101,7 +1189,7 @@ export class EntityFieldInfo extends BaseInfo {
1101
1189
  return true; // an Invalid Date is not an out-of-list value; leave it to the date check
1102
1190
  }
1103
1191
  return this._valueListDateKeys.has(value.toISOString().slice(0, 10)) ||
1104
- this._valueListDateKeys.has(EntityFieldInfo.LocalCalendarDate(value));
1192
+ this._valueListDateKeys.has(EntityFieldInfo.localCalendarDate(value));
1105
1193
  }
1106
1194
  /**
1107
1195
  * Builds the set of yyyy-mm-dd keys for a `date` field's value list, or null when the field is
@@ -1124,7 +1212,7 @@ export class EntityFieldInfo extends BaseInfo {
1124
1212
  return keys.size > 0 ? keys : null;
1125
1213
  }
1126
1214
  /** The Date's LOCAL calendar date as yyyy-mm-dd (its UTC one is `toISOString().slice(0, 10)`). */
1127
- static LocalCalendarDate(value) {
1215
+ static localCalendarDate(value) {
1128
1216
  const pad = (n) => String(n).padStart(2, '0');
1129
1217
  return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
1130
1218
  }
@@ -1872,6 +1960,8 @@ export class EntityFieldInfo extends BaseInfo {
1872
1960
  this._loggedUnsupportedValueListType = false;
1873
1961
  /** Memoized yyyy-mm-dd keys for a `date` field's value list; null when it cannot be compared. */
1874
1962
  this._valueListDateKeys = undefined;
1963
+ /** Memoized numeric form of a numeric field's value list; null when it is not entirely numeric. */
1964
+ this._valueListNumericKeys = undefined;
1875
1965
  /**
1876
1966
  * Cached parsed RelatedEntityJoinFieldsConfig to avoid repeated JSON.parse calls.
1877
1967
  * Lazy-initialized on first access.
@@ -1881,7 +1971,7 @@ export class EntityFieldInfo extends BaseInfo {
1881
1971
  * Flag to track if RelatedEntityJoinFieldsConfig parsing failed to avoid repeated parse attempts on bad JSON.
1882
1972
  */
1883
1973
  this._relatedEntityJoinFieldsFailedParsing = false;
1884
- this._FieldPermissions = [];
1974
+ this._fieldPermissions = [];
1885
1975
  /** Memoized {@link TSType} — `Type` is immutable after metadata load, so the classification never changes. */
1886
1976
  this._tsType = undefined;
1887
1977
  /**
@@ -1911,7 +2001,7 @@ export class EntityFieldInfo extends BaseInfo {
1911
2001
  // would cost more than the construction it avoids.
1912
2002
  const efp = initData.EntityFieldPermissions || initData._FieldPermissions || initData.FieldPermissions;
1913
2003
  if (efp && efp.length > 0) {
1914
- this._FieldPermissions = efp.map((p) => new EntityFieldPermissionInfo(p));
2004
+ this._fieldPermissions = efp.map((p) => new EntityFieldPermissionInfo(p));
1915
2005
  }
1916
2006
  }
1917
2007
  }
@@ -2058,7 +2148,10 @@ export class EntityInfo extends BaseInfo {
2058
2148
  try {
2059
2149
  const parsed = JSON.parse(this.SubtypeSelector);
2060
2150
  if (parsed && typeof parsed['Path'] === 'string' && parsed['Path'].trim().length > 0) {
2061
- this._subtypeSelectorConfig = { Path: parsed['Path'].trim() };
2151
+ this._subtypeSelectorConfig = {
2152
+ Path: parsed['Path'].trim(),
2153
+ UseForLoadedRecords: this.parseSubtypeSelectorUseForLoadedRecords(parsed),
2154
+ };
2062
2155
  }
2063
2156
  else {
2064
2157
  LogError(`EntityInfo '${this.Name}': SubtypeSelector JSON must contain a non-empty 'Path' string property. Found: ${this.SubtypeSelector}`);
@@ -2076,6 +2169,19 @@ export class EntityInfo extends BaseInfo {
2076
2169
  }
2077
2170
  return this._subtypeSelectorConfig;
2078
2171
  }
2172
+ /**
2173
+ * Reads the selector's optional `UseForLoadedRecords` flag. Only a JSON `true` turns load hints
2174
+ * on; a value of any other type is logged and leaves them off, so a quoted `"true"` can't look
2175
+ * like an opt-in that silently does nothing.
2176
+ */
2177
+ parseSubtypeSelectorUseForLoadedRecords(parsed) {
2178
+ const value = parsed['UseForLoadedRecords'];
2179
+ if (value === undefined || value === null || typeof value === 'boolean') {
2180
+ return value === true;
2181
+ }
2182
+ LogError(`EntityInfo '${this.Name}': SubtypeSelector 'UseForLoadedRecords' must be true or false, but is ${JSON.stringify(value)}. Loads of this entity won't use the selector.`);
2183
+ return false;
2184
+ }
2079
2185
  /**
2080
2186
  * True when at least one field is a writable Geo* source (street and/or native lat/lng).
2081
2187
  * Person/Org PrimaryAddress* are virtual display fields and do **not** count.
@@ -2083,6 +2189,62 @@ export class EntityInfo extends BaseInfo {
2083
2189
  get HasWritableGeoSourceFields() {
2084
2190
  return (this.Fields ?? []).some(f => f.IsWritableGeoField);
2085
2191
  }
2192
+ /** @deprecated Use {@link SpCreate}. */
2193
+ get spCreate() {
2194
+ return this.SpCreate;
2195
+ }
2196
+ /** @deprecated Use {@link SpCreate}. */
2197
+ set spCreate(value) {
2198
+ this.SpCreate = value;
2199
+ }
2200
+ /** @deprecated Use {@link SpUpdate}. */
2201
+ get spUpdate() {
2202
+ return this.SpUpdate;
2203
+ }
2204
+ /** @deprecated Use {@link SpUpdate}. */
2205
+ set spUpdate(value) {
2206
+ this.SpUpdate = value;
2207
+ }
2208
+ /** @deprecated Use {@link SpDelete}. */
2209
+ get spDelete() {
2210
+ return this.SpDelete;
2211
+ }
2212
+ /** @deprecated Use {@link SpDelete}. */
2213
+ set spDelete(value) {
2214
+ this.SpDelete = value;
2215
+ }
2216
+ /** @deprecated Use {@link SpCreateGenerated}. */
2217
+ get spCreateGenerated() {
2218
+ return this.SpCreateGenerated;
2219
+ }
2220
+ /** @deprecated Use {@link SpCreateGenerated}. */
2221
+ set spCreateGenerated(value) {
2222
+ this.SpCreateGenerated = value;
2223
+ }
2224
+ /** @deprecated Use {@link SpUpdateGenerated}. */
2225
+ get spUpdateGenerated() {
2226
+ return this.SpUpdateGenerated;
2227
+ }
2228
+ /** @deprecated Use {@link SpUpdateGenerated}. */
2229
+ set spUpdateGenerated(value) {
2230
+ this.SpUpdateGenerated = value;
2231
+ }
2232
+ /** @deprecated Use {@link SpDeleteGenerated}. */
2233
+ get spDeleteGenerated() {
2234
+ return this.SpDeleteGenerated;
2235
+ }
2236
+ /** @deprecated Use {@link SpDeleteGenerated}. */
2237
+ set spDeleteGenerated(value) {
2238
+ this.SpDeleteGenerated = value;
2239
+ }
2240
+ /** @deprecated Use {@link SpMatch}. */
2241
+ get spMatch() {
2242
+ return this.SpMatch;
2243
+ }
2244
+ /** @deprecated Use {@link SpMatch}. */
2245
+ set spMatch(value) {
2246
+ this.SpMatch = value;
2247
+ }
2086
2248
  /**
2087
2249
  * Optional JSON configuration bag (shape = {@link IEntityConfiguration}).
2088
2250
  * Nested `UI.Form` holds generated-form chrome: layout, auto left-nav
@@ -2117,6 +2279,46 @@ export class EntityInfo extends BaseInfo {
2117
2279
  get ConfigurationObject() {
2118
2280
  return this.Configuration;
2119
2281
  }
2282
+ /** @deprecated Use {@link HasIdField}. */
2283
+ get _hasIdField() {
2284
+ return this.HasIdField;
2285
+ }
2286
+ /** @deprecated Use {@link HasIdField}. */
2287
+ set _hasIdField(value) {
2288
+ this.HasIdField = value;
2289
+ }
2290
+ /** @deprecated Use {@link VirtualCount}. */
2291
+ get _virtualCount() {
2292
+ return this.VirtualCount;
2293
+ }
2294
+ /** @deprecated Use {@link VirtualCount}. */
2295
+ set _virtualCount(value) {
2296
+ this.VirtualCount = value;
2297
+ }
2298
+ /** @deprecated Use {@link ManyToManyCount}. */
2299
+ get _manyToManyCount() {
2300
+ return this.ManyToManyCount;
2301
+ }
2302
+ /** @deprecated Use {@link ManyToManyCount}. */
2303
+ set _manyToManyCount(value) {
2304
+ this.ManyToManyCount = value;
2305
+ }
2306
+ /** @deprecated Use {@link OneToManyCount}. */
2307
+ get _oneToManyCount() {
2308
+ return this.OneToManyCount;
2309
+ }
2310
+ /** @deprecated Use {@link OneToManyCount}. */
2311
+ set _oneToManyCount(value) {
2312
+ this.OneToManyCount = value;
2313
+ }
2314
+ /** @deprecated Use {@link FloatCount}. */
2315
+ get _floatCount() {
2316
+ return this.FloatCount;
2317
+ }
2318
+ /** @deprecated Use {@link FloatCount}. */
2319
+ set _floatCount(value) {
2320
+ this.FloatCount = value;
2321
+ }
2120
2322
  /**
2121
2323
  * The set of field names this user may NOT READ on this entity — the per-request primitive
2122
2324
  * every field-security enforcement point is built on.
@@ -2243,7 +2445,7 @@ export class EntityInfo extends BaseInfo {
2243
2445
  if (!this.EnableFieldLevelSecurity) {
2244
2446
  return denied;
2245
2447
  }
2246
- for (const field of this._Fields) {
2448
+ for (const field of this._fields) {
2247
2449
  if (isDenied(field.GetUserFieldPermissions(user, true))) {
2248
2450
  denied.add(field.Name.trim().toLowerCase());
2249
2451
  denied.add(field.CodeName.trim().toLowerCase());
@@ -2267,7 +2469,7 @@ export class EntityInfo extends BaseInfo {
2267
2469
  return undefined;
2268
2470
  if (this._fieldByNameMap === null) {
2269
2471
  const map = new Map();
2270
- for (const f of this._Fields) {
2472
+ for (const f of this._fields) {
2271
2473
  if (f.Name != null)
2272
2474
  map.set(f.Name.trim().toLowerCase(), f);
2273
2475
  }
@@ -2348,7 +2550,7 @@ export class EntityInfo extends BaseInfo {
2348
2550
  * @returns {EntityFieldInfo[]} Array of all entity fields
2349
2551
  */
2350
2552
  get Fields() {
2351
- return this._Fields;
2553
+ return this._fields;
2352
2554
  }
2353
2555
  /**
2354
2556
  * Returns true if ANY field on this entity is `Deprecated` or `Disabled` (i.e. not `Active`).
@@ -2363,7 +2565,7 @@ export class EntityInfo extends BaseInfo {
2363
2565
  */
2364
2566
  get HasInactiveFields() {
2365
2567
  if (this._hasInactiveFields === undefined) {
2366
- this._hasInactiveFields = this._Fields.some(f => f.Status === 'Deprecated' || f.Status === 'Disabled');
2568
+ this._hasInactiveFields = this._fields.some(f => f.Status === 'Deprecated' || f.Status === 'Disabled');
2367
2569
  }
2368
2570
  return this._hasInactiveFields;
2369
2571
  }
@@ -2389,7 +2591,7 @@ export class EntityInfo extends BaseInfo {
2389
2591
  */
2390
2592
  get HasSearchFields() {
2391
2593
  if (this._hasSearchFields === undefined) {
2392
- this._hasSearchFields = this._Fields.some(f => f.IncludeInUserSearchAPI);
2594
+ this._hasSearchFields = this._fields.some(f => f.IncludeInUserSearchAPI);
2393
2595
  }
2394
2596
  return this._hasSearchFields;
2395
2597
  }
@@ -2398,21 +2600,21 @@ export class EntityInfo extends BaseInfo {
2398
2600
  * @returns {EntityRelationshipInfo[]} Array of entity relationships
2399
2601
  */
2400
2602
  get RelatedEntities() {
2401
- return this._RelatedEntities;
2603
+ return this._relatedEntities;
2402
2604
  }
2403
2605
  /**
2404
2606
  * Gets the security permissions for this entity by role.
2405
2607
  * @returns {EntityPermissionInfo[]} Array of permission settings
2406
2608
  */
2407
2609
  get Permissions() {
2408
- return this._Permissions;
2610
+ return this._permissions;
2409
2611
  }
2410
2612
  /**
2411
2613
  * Gets custom configuration settings for this entity.
2412
2614
  * @returns {EntitySettingInfo[]} Array of entity-specific settings
2413
2615
  */
2414
2616
  get Settings() {
2415
- return this._Settings;
2617
+ return this._settings;
2416
2618
  }
2417
2619
  /**
2418
2620
  * Gets the parsed FieldCategoryInfo map for this entity, keyed by category name.
@@ -2420,7 +2622,7 @@ export class EntityInfo extends BaseInfo {
2420
2622
  * during EntityInfo construction. Returns null if no category info is configured.
2421
2623
  */
2422
2624
  get FieldCategories() {
2423
- return this._FieldCategories;
2625
+ return this._fieldCategories;
2424
2626
  }
2425
2627
  /**
2426
2628
  * Gets the organic keys defined on this entity. Organic keys enable cross-entity
@@ -2429,7 +2631,7 @@ export class EntityInfo extends BaseInfo {
2429
2631
  * @returns {EntityOrganicKeyInfo[]} Array of organic key definitions with their related entities
2430
2632
  */
2431
2633
  get OrganicKeys() {
2432
- return this._OrganicKeys;
2634
+ return this._organicKeys;
2433
2635
  }
2434
2636
  static { this.__createdAtFieldName = '__mj_CreatedAt'; }
2435
2637
  static { this.__updatedAtFieldName = '__mj_UpdatedAt'; }
@@ -2486,6 +2688,38 @@ export class EntityInfo extends BaseInfo {
2486
2688
  get DisplayNameOrName() {
2487
2689
  return this.DisplayName ? this.DisplayName : this.Name;
2488
2690
  }
2691
+ /**
2692
+ * Returns a business-user-friendly PLURAL of the entity's display name, e.g. "Contacts", "Companies",
2693
+ * "Addresses". Derived from `DisplayNameOrName` via `GeneratePluralName`, so a per-deployment
2694
+ * `DisplayName` override flows through (rename the entity's DisplayName to "Member" and this returns
2695
+ * "Members"). Handles irregular plurals and the common English rules (`-y` to `-ies`, `-s/ch/sh/x/z` to `-es`).
2696
+ *
2697
+ * This is the seam for surfacing the user's own domain nouns in place of the platform meta-noun
2698
+ * "entity" on business-user surfaces (empty states, counts, headers). It is display-only: never use it
2699
+ * as a lookup key. If the display name is already plural, `GeneratePluralName` returns it unchanged.
2700
+ *
2701
+ * Memoized per instance: templates read this on every change-detection pass, and the source name
2702
+ * is fixed per metadata load (the same assumption `_nameFieldCache` relies on). The cache is keyed
2703
+ * by the source name so it stays correct if `DisplayName` is ever mutated in place.
2704
+ */
2705
+ get DisplayNamePlural() {
2706
+ const source = this.DisplayNameOrName;
2707
+ if (this._displayNamePluralCache?.source !== source) {
2708
+ this._displayNamePluralCache = { source, plural: EntityInfo.matchLeadingCase(source, GeneratePluralName(source)) };
2709
+ }
2710
+ return this._displayNamePluralCache.plural;
2711
+ }
2712
+ /**
2713
+ * Gives `plural` the same leading-letter case as `source`. The irregular-plural table stores
2714
+ * lowercase values ('person' → 'people'), so without this an empty state reads "No people to
2715
+ * display". Only the first letter is touched: `capitalizeFirstLetterOnly` would also capitalize a
2716
+ * name that deliberately starts lowercase ("iPhone" → "IPhones").
2717
+ */
2718
+ static matchLeadingCase(source, plural) {
2719
+ const lead = source.charAt(0);
2720
+ const startsUpper = lead !== lead.toLowerCase();
2721
+ return startsUpper && plural.length > 0 ? plural.charAt(0).toUpperCase() + plural.slice(1) : plural;
2722
+ }
2489
2723
  /**
2490
2724
  * Returns the EntityField object for the Field that has IsNameField set to true. If multiple fields have IsNameField on, the function will return the first field (by sequence) that matches.
2491
2725
  * If no fields match, if there is a field called "Name", that is returned. If there is no field called "Name", null is returned.
@@ -3089,10 +3323,10 @@ export class EntityInfo extends BaseInfo {
3089
3323
  const params = {};
3090
3324
  const matchFields = organicKey.MatchFieldNamesArray;
3091
3325
  if (organicKeyRelatedEntity.IsTransitiveMatch) {
3092
- params.ExtraFilter = EntityInfo.BuildTransitiveOrganicKeyFilter(record, organicKeyRelatedEntity, organicKey, matchFields);
3326
+ params.ExtraFilter = EntityInfo.buildTransitiveOrganicKeyFilter(record, organicKeyRelatedEntity, organicKey, matchFields);
3093
3327
  }
3094
3328
  else {
3095
- params.ExtraFilter = EntityInfo.BuildDirectOrganicKeyFilter(record, organicKeyRelatedEntity, organicKey, matchFields);
3329
+ params.ExtraFilter = EntityInfo.buildDirectOrganicKeyFilter(record, organicKeyRelatedEntity, organicKey, matchFields);
3096
3330
  }
3097
3331
  if (filter && filter.length > 0) {
3098
3332
  params.ExtraFilter = `(${params.ExtraFilter}) AND (${filter})`;
@@ -3112,12 +3346,12 @@ export class EntityInfo extends BaseInfo {
3112
3346
  * matching organic key (same Name) to find its expression. Falls back to the hub's
3113
3347
  * expression on both sides if the spoke doesn't carry its own.
3114
3348
  */
3115
- static BuildDirectOrganicKeyFilter(record, relatedEntity, organicKey, matchFields) {
3349
+ static buildDirectOrganicKeyFilter(record, relatedEntity, organicKey, matchFields) {
3116
3350
  const relatedFields = relatedEntity.RelatedEntityFieldNamesArray;
3117
3351
  const conditions = [];
3118
3352
  // Resolve the spoke entity's own organic key (matching by Name) to pull its
3119
3353
  // per-column normalization. Falls back to the hub's expression if not found.
3120
- const spokeOrganicKey = EntityInfo.ResolveSpokeOrganicKey(relatedEntity, organicKey);
3354
+ const spokeOrganicKey = EntityInfo.resolveSpokeOrganicKey(relatedEntity, organicKey);
3121
3355
  for (let i = 0; i < matchFields.length; i++) {
3122
3356
  const value = record.Get(matchFields[i]);
3123
3357
  if (value == null) {
@@ -3126,7 +3360,7 @@ export class EntityInfo extends BaseInfo {
3126
3360
  }
3127
3361
  const relatedField = relatedFields[i] || matchFields[i];
3128
3362
  const escapedValue = String(value).replace(/'/g, "''");
3129
- conditions.push(EntityInfo.WrapBothSidesWithNormalization(`[${relatedField}]`, spokeOrganicKey ?? organicKey, escapedValue, organicKey));
3363
+ conditions.push(EntityInfo.wrapBothSidesWithNormalization(`[${relatedField}]`, spokeOrganicKey ?? organicKey, escapedValue, organicKey));
3130
3364
  }
3131
3365
  return conditions.join(' AND ');
3132
3366
  }
@@ -3135,7 +3369,7 @@ export class EntityInfo extends BaseInfo {
3135
3369
  * the spoke's own normalization function on the spoke side. Returns undefined if the
3136
3370
  * spoke entity doesn't have a parallel organic key — caller falls back to the hub's.
3137
3371
  */
3138
- static ResolveSpokeOrganicKey(relatedEntity, hubOrganicKey, provider) {
3372
+ static resolveSpokeOrganicKey(relatedEntity, hubOrganicKey, provider) {
3139
3373
  const md = provider ?? Metadata.Provider;
3140
3374
  if (!md)
3141
3375
  return undefined;
@@ -3148,7 +3382,7 @@ export class EntityInfo extends BaseInfo {
3148
3382
  /**
3149
3383
  * Builds an ExtraFilter for transitive organic key matching (via SQL view/table subquery).
3150
3384
  */
3151
- static BuildTransitiveOrganicKeyFilter(record, relatedEntity, organicKey, matchFields) {
3385
+ static buildTransitiveOrganicKeyFilter(record, relatedEntity, organicKey, matchFields) {
3152
3386
  const transitiveMatchFields = relatedEntity.TransitiveObjectMatchFieldNamesArray;
3153
3387
  const conditions = [];
3154
3388
  // Transitive bridge values are normalized using the HUB's expression on both sides —
@@ -3161,7 +3395,7 @@ export class EntityInfo extends BaseInfo {
3161
3395
  }
3162
3396
  const transitiveField = transitiveMatchFields[i] || matchFields[i];
3163
3397
  const escapedValue = String(value).replace(/'/g, "''");
3164
- conditions.push(EntityInfo.WrapWithNormalization(`[${transitiveField}]`, organicKey, escapedValue));
3398
+ conditions.push(EntityInfo.wrapWithNormalization(`[${transitiveField}]`, organicKey, escapedValue));
3165
3399
  }
3166
3400
  // TransitiveObjectName is schema-qualified (e.g. "mailchimp.vwBridge") — bracket each part separately
3167
3401
  const transitiveObject = relatedEntity.TransitiveObjectName.split('.').map(p => `[${p}]`).join('.');
@@ -3172,7 +3406,7 @@ export class EntityInfo extends BaseInfo {
3172
3406
  * based on the organic key's NormalizationStrategy.
3173
3407
  * Returns a SQL comparison expression like: LOWER(LTRIM(RTRIM([Field]))) = LOWER(LTRIM(RTRIM('value')))
3174
3408
  */
3175
- static WrapWithNormalization(fieldExpression, organicKey, escapedValue) {
3409
+ static wrapWithNormalization(fieldExpression, organicKey, escapedValue) {
3176
3410
  switch (organicKey.NormalizationStrategy) {
3177
3411
  case 'LowerCaseTrim':
3178
3412
  return `LOWER(LTRIM(RTRIM(${fieldExpression}))) = LOWER(LTRIM(RTRIM('${escapedValue}')))`;
@@ -3204,13 +3438,13 @@ export class EntityInfo extends BaseInfo {
3204
3438
  * when the expressions agree; different transforms applied independently when they
3205
3439
  * don't (the per-column normalization case).
3206
3440
  */
3207
- static WrapBothSidesWithNormalization(fieldExpression, fieldOrganicKey, escapedValue, valueOrganicKey) {
3208
- const leftSide = EntityInfo.NormalizeFieldExpression(fieldExpression, fieldOrganicKey);
3209
- const rightSide = EntityInfo.NormalizeLiteralExpression(escapedValue, valueOrganicKey);
3441
+ static wrapBothSidesWithNormalization(fieldExpression, fieldOrganicKey, escapedValue, valueOrganicKey) {
3442
+ const leftSide = EntityInfo.normalizeFieldExpression(fieldExpression, fieldOrganicKey);
3443
+ const rightSide = EntityInfo.normalizeLiteralExpression(escapedValue, valueOrganicKey);
3210
3444
  return `${leftSide} = ${rightSide}`;
3211
3445
  }
3212
3446
  /** Apply an organic key's normalization to a SQL field expression (left side of compare). */
3213
- static NormalizeFieldExpression(fieldExpression, organicKey) {
3447
+ static normalizeFieldExpression(fieldExpression, organicKey) {
3214
3448
  switch (organicKey.NormalizationStrategy) {
3215
3449
  case 'LowerCaseTrim': return `LOWER(LTRIM(RTRIM(${fieldExpression})))`;
3216
3450
  case 'Trim': return `LTRIM(RTRIM(${fieldExpression}))`;
@@ -3226,7 +3460,7 @@ export class EntityInfo extends BaseInfo {
3226
3460
  }
3227
3461
  }
3228
3462
  /** Apply an organic key's normalization to a quoted literal value (right side of compare). */
3229
- static NormalizeLiteralExpression(escapedValue, organicKey) {
3463
+ static normalizeLiteralExpression(escapedValue, organicKey) {
3230
3464
  switch (organicKey.NormalizationStrategy) {
3231
3465
  case 'LowerCaseTrim': return `LOWER(LTRIM(RTRIM('${escapedValue}')))`;
3232
3466
  case 'Trim': return `LTRIM(RTRIM('${escapedValue}'))`;
@@ -3522,27 +3756,27 @@ export class EntityInfo extends BaseInfo {
3522
3756
  /**
3523
3757
  * Name of the stored procedure for creating records
3524
3758
  */
3525
- this.spCreate = null;
3759
+ this.SpCreate = null;
3526
3760
  /**
3527
3761
  * Name of the stored procedure for updating records
3528
3762
  */
3529
- this.spUpdate = null;
3763
+ this.SpUpdate = null;
3530
3764
  /**
3531
3765
  * Name of the stored procedure for deleting records
3532
3766
  */
3533
- this.spDelete = null;
3767
+ this.SpDelete = null;
3534
3768
  /**
3535
3769
  * Whether the create stored procedure is generated by CodeGen
3536
3770
  */
3537
- this.spCreateGenerated = null;
3771
+ this.SpCreateGenerated = null;
3538
3772
  /**
3539
3773
  * Whether the update stored procedure is generated by CodeGen
3540
3774
  */
3541
- this.spUpdateGenerated = null;
3775
+ this.SpUpdateGenerated = null;
3542
3776
  /**
3543
3777
  * Whether the delete stored procedure is generated by CodeGen
3544
3778
  */
3545
- this.spDeleteGenerated = null;
3779
+ this.SpDeleteGenerated = null;
3546
3780
  /**
3547
3781
  * Whether to automatically delete related records when a parent is deleted
3548
3782
  */
@@ -3558,7 +3792,7 @@ export class EntityInfo extends BaseInfo {
3558
3792
  /**
3559
3793
  * Name of the stored procedure used for matching/duplicate detection
3560
3794
  */
3561
- this.spMatch = null;
3795
+ this.SpMatch = null;
3562
3796
  /**
3563
3797
  * Default display type for relationships: Search (type-ahead) or Dropdown
3564
3798
  */
@@ -3683,13 +3917,13 @@ export class EntityInfo extends BaseInfo {
3683
3917
  this.ParentEntity = null;
3684
3918
  this.ParentBaseTable = null;
3685
3919
  this.ParentBaseView = null;
3686
- this._FieldCategories = null;
3687
- this._OrganicKeys = [];
3688
- this._hasIdField = false;
3689
- this._virtualCount = 0;
3690
- this._manyToManyCount = 0;
3691
- this._oneToManyCount = 0;
3692
- this._floatCount = 0;
3920
+ this._fieldCategories = null;
3921
+ this._organicKeys = [];
3922
+ this.HasIdField = false;
3923
+ this.VirtualCount = 0;
3924
+ this.ManyToManyCount = 0;
3925
+ this.OneToManyCount = 0;
3926
+ this.FloatCount = 0;
3693
3927
  // --- Lazy caches for immutable field-derived collections ---------------------------------
3694
3928
  // `_Fields` is populated once in the constructor and never reassigned, so these caches never
3695
3929
  // need invalidation. They replace per-access `.filter()`/`.find()` scans on hot paths
@@ -3702,6 +3936,8 @@ export class EntityInfo extends BaseInfo {
3702
3936
  this._encryptedFieldsCache = null;
3703
3937
  this._datetimeFieldsCache = null;
3704
3938
  this._nameFieldCache = undefined;
3939
+ /** Memoized computed plural, keyed by the display name it was derived from (see `DisplayNamePlural`). */
3940
+ this._displayNamePluralCache = undefined;
3705
3941
  this._hasInactiveFields = undefined;
3706
3942
  this._hasSearchFields = undefined;
3707
3943
  // Cache for ParentChain to avoid repeated walks
@@ -3712,7 +3948,7 @@ export class EntityInfo extends BaseInfo {
3712
3948
  this.copyInitData(initData);
3713
3949
  // do some special handling to create class instances instead of just data objects
3714
3950
  // copy the Entity Fields (accept EntityFields, _Fields, or Fields as input names)
3715
- this._Fields = [];
3951
+ this._fields = [];
3716
3952
  // Reset every lazy field-derived memo cache whenever _Fields is (re)assigned.
3717
3953
  // `entityInfo.cacheReset.test.ts` mirrors this list in `runProductionCacheReset`;
3718
3954
  // a cache added here needs adding there too, or its reset goes unexercised.
@@ -3739,27 +3975,27 @@ export class EntityInfo extends BaseInfo {
3739
3975
  const ef = initData.EntityFields || initData._Fields || initData.Fields;
3740
3976
  if (ef) {
3741
3977
  for (let j = 0; j < ef.length; j++) {
3742
- this._Fields.push(new EntityFieldInfo(ef[j]));
3978
+ this._fields.push(new EntityFieldInfo(ef[j]));
3743
3979
  }
3744
3980
  }
3745
3981
  // copy the Entity Permissions
3746
- this._Permissions = [];
3982
+ this._permissions = [];
3747
3983
  const ep = initData.EntityPermissions || initData._Permissions || initData.Permissions;
3748
3984
  if (ep) {
3749
3985
  for (let j = 0; j < ep.length; j++) {
3750
- this._Permissions.push(new EntityPermissionInfo(ep[j]));
3986
+ this._permissions.push(new EntityPermissionInfo(ep[j]));
3751
3987
  }
3752
3988
  }
3753
3989
  // copy the Entity settings
3754
- this._Settings = [];
3990
+ this._settings = [];
3755
3991
  const es = initData.EntitySettings || initData._Settings || initData.Settings;
3756
3992
  if (es) {
3757
- es.map((s) => this._Settings.push(new EntitySettingInfo(s)));
3993
+ es.map((s) => this._settings.push(new EntitySettingInfo(s)));
3758
3994
  }
3759
3995
  // auto-populate FieldCategories from the FieldCategoryInfo setting
3760
- this._FieldCategories = this.parseFieldCategoriesFromSettings();
3996
+ this._fieldCategories = this.parseFieldCategoriesFromSettings();
3761
3997
  // copy the Related Entities (accept EntityRelationships, _RelatedEntities, or RelatedEntities as input names)
3762
- this._RelatedEntities = [];
3998
+ this._relatedEntities = [];
3763
3999
  const er = initData.EntityRelationships || initData._RelatedEntities || initData.RelatedEntities;
3764
4000
  if (er) {
3765
4001
  // check to see if ANY of the records in the er array have a non-null or non-zero sequence value. The reason is
@@ -3782,15 +4018,15 @@ export class EntityInfo extends BaseInfo {
3782
4018
  }
3783
4019
  // now that we have prepared the er array by sorting it, if needed, let's load up the related entities
3784
4020
  for (let j = 0; j < er.length; j++) {
3785
- this._RelatedEntities.push(new EntityRelationshipInfo(er[j]));
4021
+ this._relatedEntities.push(new EntityRelationshipInfo(er[j]));
3786
4022
  }
3787
4023
  }
3788
4024
  // copy the Organic Keys (sorted by sequence inside EntityOrganicKeyInfo constructor)
3789
- this._OrganicKeys = [];
4025
+ this._organicKeys = [];
3790
4026
  const ok = initData.EntityOrganicKeys || initData._OrganicKeys || initData.OrganicKeys;
3791
4027
  if (ok && Array.isArray(ok)) {
3792
4028
  for (const item of ok) {
3793
- this._OrganicKeys.push(new EntityOrganicKeyInfo(item));
4029
+ this._organicKeys.push(new EntityOrganicKeyInfo(item));
3794
4030
  }
3795
4031
  }
3796
4032
  this.prepareSpecialFields();
@@ -3810,9 +4046,9 @@ export class EntityInfo extends BaseInfo {
3810
4046
  virtualCount += f.IsVirtual ? 1 : 0;
3811
4047
  floatCount += f.IsFloat ? 1 : 0;
3812
4048
  }
3813
- this._hasIdField = hasIdField;
3814
- this._floatCount = floatCount;
3815
- this._virtualCount = virtualCount;
4049
+ this.HasIdField = hasIdField;
4050
+ this.FloatCount = floatCount;
4051
+ this.VirtualCount = virtualCount;
3816
4052
  // now see if there are any relationships and count the one to many and many to many
3817
4053
  for (let j = 0; j < this.RelatedEntities.length; ++j) {
3818
4054
  const r = this.RelatedEntities[j];
@@ -3821,8 +4057,8 @@ export class EntityInfo extends BaseInfo {
3821
4057
  else
3822
4058
  manyToManyCount++;
3823
4059
  }
3824
- this._manyToManyCount = manyToManyCount;
3825
- this._oneToManyCount = oneToManyCount;
4060
+ this.ManyToManyCount = manyToManyCount;
4061
+ this.OneToManyCount = oneToManyCount;
3826
4062
  }
3827
4063
  catch (e) {
3828
4064
  LogError(e);
@@ -3833,11 +4069,11 @@ export class EntityInfo extends BaseInfo {
3833
4069
  * Called once during construction so the result is cached on _FieldCategories.
3834
4070
  */
3835
4071
  parseFieldCategoriesFromSettings() {
3836
- if (!this._Settings || this._Settings.length === 0) {
4072
+ if (!this._settings || this._settings.length === 0) {
3837
4073
  return null;
3838
4074
  }
3839
4075
  // Try new format first
3840
- const infoSetting = this._Settings.find(s => s.Name === 'FieldCategoryInfo');
4076
+ const infoSetting = this._settings.find(s => s.Name === 'FieldCategoryInfo');
3841
4077
  if (infoSetting?.Value) {
3842
4078
  const parsed = SafeJSONParse(infoSetting.Value, false);
3843
4079
  if (parsed) {
@@ -3845,7 +4081,7 @@ export class EntityInfo extends BaseInfo {
3845
4081
  }
3846
4082
  }
3847
4083
  // Fallback to legacy FieldCategoryIcons format (icon-only map)
3848
- const iconSetting = this._Settings.find(s => s.Name === 'FieldCategoryIcons');
4084
+ const iconSetting = this._settings.find(s => s.Name === 'FieldCategoryIcons');
3849
4085
  if (iconSetting?.Value) {
3850
4086
  const icons = SafeJSONParse(iconSetting.Value, false);
3851
4087
  if (icons) {