@memberjunction/core 6.2.0-edge.4 → 6.2.0-edge.5

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 (73) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +7 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +22 -4
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +18 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/baseEngine.d.ts +29 -5
  8. package/dist/generic/baseEngine.d.ts.map +1 -1
  9. package/dist/generic/baseEngine.js +117 -11
  10. package/dist/generic/baseEngine.js.map +1 -1
  11. package/dist/generic/baseEntity.d.ts +45 -1
  12. package/dist/generic/baseEntity.d.ts.map +1 -1
  13. package/dist/generic/baseEntity.js +308 -187
  14. package/dist/generic/baseEntity.js.map +1 -1
  15. package/dist/generic/baseInfo.d.ts +31 -3
  16. package/dist/generic/baseInfo.d.ts.map +1 -1
  17. package/dist/generic/baseInfo.js +138 -40
  18. package/dist/generic/baseInfo.js.map +1 -1
  19. package/dist/generic/databaseProviderBase.d.ts +37 -8
  20. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  21. package/dist/generic/databaseProviderBase.js +162 -65
  22. package/dist/generic/databaseProviderBase.js.map +1 -1
  23. package/dist/generic/entityConfiguration.d.ts +8 -2
  24. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  25. package/dist/generic/entityConfiguration.js +9 -0
  26. package/dist/generic/entityConfiguration.js.map +1 -1
  27. package/dist/generic/entityInfo.d.ts +78 -3
  28. package/dist/generic/entityInfo.d.ts.map +1 -1
  29. package/dist/generic/entityInfo.js +348 -55
  30. package/dist/generic/entityInfo.js.map +1 -1
  31. package/dist/generic/infoProviderResolution.d.ts +16 -0
  32. package/dist/generic/infoProviderResolution.d.ts.map +1 -0
  33. package/dist/generic/infoProviderResolution.js +17 -0
  34. package/dist/generic/infoProviderResolution.js.map +1 -0
  35. package/dist/generic/interfaces.d.ts +25 -1
  36. package/dist/generic/interfaces.d.ts.map +1 -1
  37. package/dist/generic/interfaces.js.map +1 -1
  38. package/dist/generic/localCacheManager.d.ts +238 -40
  39. package/dist/generic/localCacheManager.d.ts.map +1 -1
  40. package/dist/generic/localCacheManager.js +466 -71
  41. package/dist/generic/localCacheManager.js.map +1 -1
  42. package/dist/generic/mjapiUrl.d.ts +21 -0
  43. package/dist/generic/mjapiUrl.d.ts.map +1 -0
  44. package/dist/generic/mjapiUrl.js +25 -0
  45. package/dist/generic/mjapiUrl.js.map +1 -0
  46. package/dist/generic/providerBase.d.ts +118 -3
  47. package/dist/generic/providerBase.d.ts.map +1 -1
  48. package/dist/generic/providerBase.js +398 -100
  49. package/dist/generic/providerBase.js.map +1 -1
  50. package/dist/generic/queryInfo.d.ts +10 -9
  51. package/dist/generic/queryInfo.d.ts.map +1 -1
  52. package/dist/generic/queryInfo.js +60 -33
  53. package/dist/generic/queryInfo.js.map +1 -1
  54. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  55. package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
  56. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  57. package/dist/generic/securityInfo.d.ts +6 -6
  58. package/dist/generic/securityInfo.d.ts.map +1 -1
  59. package/dist/generic/securityInfo.js +10 -10
  60. package/dist/generic/securityInfo.js.map +1 -1
  61. package/dist/generic/util.d.ts.map +1 -1
  62. package/dist/generic/util.js +3 -0
  63. package/dist/generic/util.js.map +1 -1
  64. package/dist/generic/wellKnownUserSource.d.ts +3 -2
  65. package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
  66. package/dist/generic/wellKnownUserSource.js +18 -9
  67. package/dist/generic/wellKnownUserSource.js.map +1 -1
  68. package/dist/index.d.ts +1 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1 -0
  71. package/dist/index.js.map +1 -1
  72. package/package.json +3 -3
  73. package/readme.md +29 -4
@@ -1,5 +1,6 @@
1
1
  import { BaseInfo } from "./baseInfo.js";
2
2
  import { Metadata } from "./metadata.js";
3
+ import { MetadataProviderFor } from "./infoProviderResolution.js";
3
4
  import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, SQLMaxByteLength, FormatValue, CodeNameFromString } from "./util.js";
4
5
  import { IsFixedWidthStringSQLType } from "@memberjunction/sql-dialect";
5
6
  import { LogError } from "./logging.js";
@@ -369,9 +370,13 @@ export class EntityPermissionInfo extends BaseInfo {
369
370
  break;
370
371
  }
371
372
  if (fID && fID.length > 0)
372
- return Metadata.Provider.RowLevelSecurityFilters.find(f => UUIDsEqual(f.ID, fID)); // global-provider-ok: stateless info class — proxies to global metadata
373
+ return MetadataProviderFor(this)?.RowLevelSecurityFilters?.find(f => UUIDsEqual(f.ID, fID));
373
374
  }
374
- constructor(initData) {
375
+ /**
376
+ * @param initData - the raw permission row
377
+ * @param md - the provider that owns this metadata; RLS filters resolve through it (see {@link BaseInfo.OwningProvider})
378
+ */
379
+ constructor(initData, md) {
375
380
  super();
376
381
  this.ID = null;
377
382
  this.EntityID = null;
@@ -401,6 +406,7 @@ export class EntityPermissionInfo extends BaseInfo {
401
406
  this.CreateRLSFilter = null;
402
407
  this.UpdateRLSFilter = null;
403
408
  this.DeleteRLSFilter = null;
409
+ this.SetOwningProvider(md);
404
410
  this.copyInitData(initData);
405
411
  }
406
412
  }
@@ -927,14 +933,16 @@ export class EntityFieldInfo extends BaseInfo {
927
933
  static AggregateFieldRulesForUser(rules, user) {
928
934
  const allow = { CanRead: false, CanUpdate: false, CanCreate: false };
929
935
  const deny = { CanRead: false, CanUpdate: false, CanCreate: false };
930
- for (const fp of rules) {
931
- const roleMatch = user?.UserRoles?.find((r) => UUIDsEqual(r.RoleID, fp.RoleID));
932
- if (!roleMatch) {
933
- continue; // user does not hold this role
936
+ const userRoleIds = getUserRoleIdSet(user);
937
+ if (userRoleIds.size > 0) {
938
+ for (const fp of rules) {
939
+ if (!fp.RoleID || !userRoleIds.has(fp.RoleID.trim().toLowerCase())) {
940
+ continue; // user does not hold this role
941
+ }
942
+ EntityFieldInfo.applyAccessToBuckets(fp.ReadAccess, allow, deny, 'CanRead');
943
+ EntityFieldInfo.applyAccessToBuckets(fp.UpdateAccess, allow, deny, 'CanUpdate');
944
+ EntityFieldInfo.applyAccessToBuckets(fp.CreateAccess, allow, deny, 'CanCreate');
934
945
  }
935
- EntityFieldInfo.applyAccessToBuckets(fp.ReadAccess, allow, deny, 'CanRead');
936
- EntityFieldInfo.applyAccessToBuckets(fp.UpdateAccess, allow, deny, 'CanUpdate');
937
- EntityFieldInfo.applyAccessToBuckets(fp.CreateAccess, allow, deny, 'CanCreate');
938
946
  }
939
947
  const effective = {
940
948
  CanRead: allow.CanRead && !deny.CanRead,
@@ -971,17 +979,23 @@ export class EntityFieldInfo extends BaseInfo {
971
979
  * Returns the ValueListType using the EntityFieldValueListType enum.
972
980
  */
973
981
  get ValueListTypeEnum() {
974
- if (this.ValueListType == null)
975
- return EntityFieldValueListType.None;
976
- else {
977
- // iterate through list of possibilities from enum and compare lcase
978
- for (let enumMember in EntityFieldValueListType) {
979
- if (typeof EntityFieldValueListType[enumMember] === 'string' &&
980
- enumMember.toLowerCase().trim() === this.ValueListType.toLowerCase().trim()) {
981
- return EntityFieldValueListType[enumMember];
982
- }
982
+ if (this._valueListTypeEnumCache !== undefined) {
983
+ return this._valueListTypeEnumCache;
984
+ }
985
+ if (this.ValueListType == null) {
986
+ this._valueListTypeEnumCache = EntityFieldValueListType.None;
987
+ return this._valueListTypeEnumCache;
988
+ }
989
+ const target = this.ValueListType.toLowerCase().trim();
990
+ for (const enumMember in EntityFieldValueListType) {
991
+ if (typeof EntityFieldValueListType[enumMember] === 'string' &&
992
+ enumMember.toLowerCase().trim() === target) {
993
+ this._valueListTypeEnumCache = EntityFieldValueListType[enumMember];
994
+ return this._valueListTypeEnumCache;
983
995
  }
984
996
  }
997
+ this._valueListTypeEnumCache = EntityFieldValueListType.None;
998
+ return this._valueListTypeEnumCache;
985
999
  }
986
1000
  /**
987
1001
  * Upper bound on how many legal values a validation message enumerates before it truncates.
@@ -1458,19 +1472,28 @@ export class EntityFieldInfo extends BaseInfo {
1458
1472
  * Returns true if the field is the CreatedAt field, a special field that is used to track the creation date of a record. This is only used when the entity has TrackRecordChanges=1
1459
1473
  */
1460
1474
  get IsCreatedAtField() {
1461
- return this.Name.trim().toLowerCase() === EntityInfo.CreatedAtFieldName.trim().toLowerCase();
1475
+ if (this._isCreatedAtFieldCache !== undefined)
1476
+ return this._isCreatedAtFieldCache;
1477
+ this._isCreatedAtFieldCache = this.Name.trim().toLowerCase() === EntityInfo.CreatedAtFieldName.trim().toLowerCase();
1478
+ return this._isCreatedAtFieldCache;
1462
1479
  }
1463
1480
  /**
1464
1481
  * Returns true if the field is the UpdatedAt field, a special field that is used to track the last update date of a record. This is only used when the entity has TrackRecordChanges=1
1465
1482
  */
1466
1483
  get IsUpdatedAtField() {
1467
- return this.Name.trim().toLowerCase() === EntityInfo.UpdatedAtFieldName.trim().toLowerCase();
1484
+ if (this._isUpdatedAtFieldCache !== undefined)
1485
+ return this._isUpdatedAtFieldCache;
1486
+ this._isUpdatedAtFieldCache = this.Name.trim().toLowerCase() === EntityInfo.UpdatedAtFieldName.trim().toLowerCase();
1487
+ return this._isUpdatedAtFieldCache;
1468
1488
  }
1469
1489
  /**
1470
1490
  * Returns true if the field is the DeletedAt field, a special field that is used to track the deletion date of a record. This is only used when the entity has DeleteType=Soft
1471
1491
  */
1472
1492
  get IsDeletedAtField() {
1473
- return this.Name.trim().toLowerCase() === EntityInfo.DeletedAtFieldName.trim().toLowerCase();
1493
+ if (this._isDeletedAtFieldCache !== undefined)
1494
+ return this._isDeletedAtFieldCache;
1495
+ this._isDeletedAtFieldCache = this.Name.trim().toLowerCase() === EntityInfo.DeletedAtFieldName.trim().toLowerCase();
1496
+ return this._isDeletedAtFieldCache;
1474
1497
  }
1475
1498
  /**
1476
1499
  * Returns true if the field is a GUID/UUID column in the database.
@@ -1479,8 +1502,11 @@ export class EntityFieldInfo extends BaseInfo {
1479
1502
  * `uuid`, so a `uniqueidentifier`-only check would miss every UUID column.
1480
1503
  */
1481
1504
  get IsUniqueIdentifier() {
1505
+ if (this._isUniqueIdentifierCache !== undefined)
1506
+ return this._isUniqueIdentifierCache;
1482
1507
  const t = this.Type.trim().toLowerCase();
1483
- return t === 'uniqueidentifier' || t === 'uuid';
1508
+ this._isUniqueIdentifierCache = t === 'uniqueidentifier' || t === 'uuid';
1509
+ return this._isUniqueIdentifierCache;
1484
1510
  }
1485
1511
  /**
1486
1512
  * Returns true if the field's SQL type is **fixed-width / space-padded**
@@ -1493,7 +1519,10 @@ export class EntityFieldInfo extends BaseInfo {
1493
1519
  * (un-padded) form of the value.
1494
1520
  */
1495
1521
  get FixedWidthColumn() {
1496
- return IsFixedWidthStringSQLType(this.Type);
1522
+ if (this._fixedWidthColumnCache !== undefined)
1523
+ return this._fixedWidthColumnCache;
1524
+ this._fixedWidthColumnCache = IsFixedWidthStringSQLType(this.Type);
1525
+ return this._fixedWidthColumnCache;
1497
1526
  }
1498
1527
  /**
1499
1528
  * Returns true if the field has a default value set
@@ -1623,6 +1652,10 @@ export class EntityFieldInfo extends BaseInfo {
1623
1652
  FormatValue(value, decimals = 2, currency = 'USD', maxLength = 0, trailingChars = "...") {
1624
1653
  return FormatValue(this.Type, value, decimals, currency, maxLength, trailingChars);
1625
1654
  }
1655
+ static { this.CHILD_INIT_KEYS = new Set([
1656
+ 'EntityFieldValues', '_EntityFieldValues',
1657
+ 'EntityFieldPermissions', '_FieldPermissions', 'FieldPermissions',
1658
+ ]); }
1626
1659
  constructor(initData = null) {
1627
1660
  super();
1628
1661
  this.ID = null;
@@ -2007,6 +2040,7 @@ export class EntityFieldInfo extends BaseInfo {
2007
2040
  */
2008
2041
  this._relatedEntityJoinFieldsFailedParsing = false;
2009
2042
  this._fieldPermissions = [];
2043
+ this._valueListTypeEnumCache = undefined;
2010
2044
  /** Memoized {@link TSType} — `Type` is immutable after metadata load, so the classification never changes. */
2011
2045
  this._tsType = undefined;
2012
2046
  /**
@@ -2014,8 +2048,14 @@ export class EntityFieldInfo extends BaseInfo {
2014
2048
  * This property returns a consistent CodeName you can use everywhere to refer to the field when generated variable names
2015
2049
  */
2016
2050
  this._codeName = null;
2051
+ this._isCreatedAtFieldCache = undefined;
2052
+ this._isUpdatedAtFieldCache = undefined;
2053
+ this._isDeletedAtFieldCache = undefined;
2054
+ this._isUniqueIdentifierCache = undefined;
2055
+ this._fixedWidthColumnCache = undefined;
2056
+ this._parsedDefaultValueCache = undefined;
2017
2057
  if (initData) {
2018
- this.copyInitData(initData);
2058
+ this.copyInitData(initData, EntityFieldInfo.CHILD_INIT_KEYS);
2019
2059
  // Lazy-construct EntityFieldValueInfo objects — stash raw data now,
2020
2060
  // construct typed instances on first access via the getter.
2021
2061
  // This eliminates ~36,000 object constructions during startup deserialization.
@@ -2096,7 +2136,99 @@ export class EntityFieldInfo extends BaseInfo {
2096
2136
  // Using includes() because the value might be wrapped in parentheses like (getdate())
2097
2137
  return EntityFieldInfo.SQL_CURRENT_DATE_FUNCTIONS.some(func => normalizedValue.includes(func));
2098
2138
  }
2139
+ static { this.PG_TYPED_LITERAL_REGEX = /^'((?:[^']|'')*)'::[A-Za-z][\w "()\[\],]*$/; }
2140
+ /**
2141
+ * Parses a field's SQL DefaultValue into its runtime JS representation.
2142
+ * Exposed as a static helper so unit tests passing plain-object mocks for EntityFieldInfo
2143
+ * work identically to real EntityFieldInfo instances.
2144
+ */
2145
+ static ParseDefaultValue(field) {
2146
+ const rawDefault = field.DefaultValue;
2147
+ if (!rawDefault) {
2148
+ return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
2149
+ }
2150
+ if (field.TSType === EntityFieldTSType.Boolean) {
2151
+ const val = typeof rawDefault === 'string' &&
2152
+ (rawDefault.trim() === '1' || rawDefault.trim().toLowerCase() === 'true');
2153
+ return { source: rawDefault, hasValue: true, value: val, isCurrentDateFunction: false };
2154
+ }
2155
+ if (field.TSType === EntityFieldTSType.Number) {
2156
+ const num = Number(rawDefault);
2157
+ if (!isNaN(num)) {
2158
+ return { source: rawDefault, hasValue: true, value: num, isCurrentDateFunction: false };
2159
+ }
2160
+ if (typeof rawDefault === 'string' && rawDefault.trim().toLowerCase() === 'null') {
2161
+ return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
2162
+ }
2163
+ return { source: rawDefault, hasValue: false, value: undefined, isCurrentDateFunction: false };
2164
+ }
2165
+ const isUniqueId = field.IsUniqueIdentifier !== undefined
2166
+ ? field.IsUniqueIdentifier
2167
+ : (typeof field.Type === 'string' && (field.Type.trim().toLowerCase() === 'uniqueidentifier' || field.Type.trim().toLowerCase() === 'uuid'));
2168
+ if (isUniqueId) {
2169
+ return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
2170
+ }
2171
+ if (field.TSType === EntityFieldTSType.Date) {
2172
+ if (typeof rawDefault === 'string' && rawDefault.trim().length > 0) {
2173
+ const temp = new Date(rawDefault);
2174
+ if (!isNaN(temp.getTime())) {
2175
+ return { source: rawDefault, hasValue: true, value: temp, isCurrentDateFunction: false };
2176
+ }
2177
+ if (EntityFieldInfo.IsDefaultValueSQLCurrentDateFunction(rawDefault)) {
2178
+ if (field.IsSpecialDateField) {
2179
+ return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
2180
+ }
2181
+ return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: true };
2182
+ }
2183
+ throw new Error(`Invalid default value for ${field.Entity}.${field.Name} of ${rawDefault}. Default values for date fields must be a valid date or a getdate() type default`);
2184
+ }
2185
+ return { source: rawDefault, hasValue: false, value: undefined, isCurrentDateFunction: false };
2186
+ }
2187
+ const dv = typeof rawDefault === 'string' ? rawDefault.trim() : String(rawDefault);
2188
+ const m = dv.match(EntityFieldInfo.PG_TYPED_LITERAL_REGEX);
2189
+ const strVal = m ? m[1].replace(/''/g, "'") : rawDefault;
2190
+ return { source: rawDefault, hasValue: true, value: strVal, isCurrentDateFunction: false };
2191
+ }
2192
+ /**
2193
+ * Parses and memoizes the SQL DefaultValue into its runtime JS representation once per
2194
+ * EntityFieldInfo instead of re-parsing (trimming, regex matching, Number/Date parsing)
2195
+ * on every EntityField instantiation.
2196
+ */
2197
+ GetParsedDefaultValue() {
2198
+ const rawDefault = this.DefaultValue;
2199
+ if (this._parsedDefaultValueCache !== undefined && this._parsedDefaultValueCache.source === rawDefault) {
2200
+ return this._parsedDefaultValueCache;
2201
+ }
2202
+ this._parsedDefaultValueCache = EntityFieldInfo.ParseDefaultValue(this);
2203
+ return this._parsedDefaultValueCache;
2204
+ }
2099
2205
  }
2206
+ const _emptyStringSet = new Set();
2207
+ const _userRoleIdSetCache = new WeakMap();
2208
+ function getUserRoleIdSet(user) {
2209
+ if (!user?.UserRoles || user.UserRoles.length === 0) {
2210
+ return _emptyStringSet;
2211
+ }
2212
+ const roles = user.UserRoles;
2213
+ const len = roles.length;
2214
+ const firstId = roles[0]?.RoleID;
2215
+ const cached = _userRoleIdSetCache.get(user);
2216
+ if (cached && cached.rolesRef === roles && cached.rolesLen === len && cached.firstRoleId === firstId) {
2217
+ return cached.roleIds;
2218
+ }
2219
+ const roleIds = new Set();
2220
+ for (let i = 0; i < len; i++) {
2221
+ const rid = roles[i]?.RoleID;
2222
+ if (rid) {
2223
+ roleIds.add(rid.trim().toLowerCase());
2224
+ }
2225
+ }
2226
+ _userRoleIdSetCache.set(user, { rolesRef: roles, rolesLen: len, firstRoleId: firstId, roleIds });
2227
+ return roleIds;
2228
+ }
2229
+ const _entityFieldIndexCache = new WeakMap();
2230
+ const _entityDeniedFieldsCache = new WeakMap();
2231
+ const _entityChildEntitiesCache = new WeakMap();
2100
2232
  /**
2101
2233
  * Entity Document Type Info object has information about the document types that exist across all entities. When Entity Documents are created they are associated with a document type.
2102
2234
  */
@@ -2327,6 +2459,30 @@ export class EntityInfo extends BaseInfo {
2327
2459
  get CloneConfig() {
2328
2460
  return this.ConfigurationObject?.Clone ?? null;
2329
2461
  }
2462
+ /**
2463
+ * Controls which columns a save operation (`spCreate` / `spUpdate`) returns from the database
2464
+ * for this entity. Defaults to `'full'` when `Configuration.SaveReturnMode` is omitted or not
2465
+ * `'datestamps_only'`.
2466
+ */
2467
+ get SaveReturnMode() {
2468
+ return this.ConfigurationObject?.SaveReturnMode === 'datestamps_only' ? 'datestamps_only' : 'full';
2469
+ }
2470
+ /**
2471
+ * Returns the ordered list of base-table fields returned by a `'datestamps_only'` save:
2472
+ * the entity's primary key field(s), followed by `__mj_CreatedAt` and `__mj_UpdatedAt`
2473
+ * when those columns exist on the entity's base table.
2474
+ */
2475
+ get DatestampReturnFields() {
2476
+ const result = [...this.PrimaryKeys];
2477
+ const pkNames = new Set(result.map(k => k.Name.trim().toLowerCase()));
2478
+ const createdAt = this.Fields.find(f => !f.IsVirtual && f.IsCreatedAtField && !pkNames.has(f.Name.trim().toLowerCase()));
2479
+ if (createdAt)
2480
+ result.push(createdAt);
2481
+ const updatedAt = this.Fields.find(f => !f.IsVirtual && f.IsUpdatedAtField && !pkNames.has(f.Name.trim().toLowerCase()));
2482
+ if (updatedAt)
2483
+ result.push(updatedAt);
2484
+ return result;
2485
+ }
2330
2486
  /**
2331
2487
  * Returns true if cloning is explicitly enabled for this entity as a ROOT record.
2332
2488
  */
@@ -2394,7 +2550,7 @@ export class EntityInfo extends BaseInfo {
2394
2550
  * `size === 0` as the single "nothing to do" condition.
2395
2551
  */
2396
2552
  GetDeniedReadFields(user) {
2397
- return this.getDeniedFields(user, (p) => !p.CanRead);
2553
+ return this.getCachedDeniedFields(user, 'read', (p) => !p.CanRead);
2398
2554
  }
2399
2555
  /**
2400
2556
  * Whether this user may READ the named field — the single-field question, answered the same
@@ -2463,7 +2619,7 @@ export class EntityInfo extends BaseInfo {
2463
2619
  * actually need rather than relying on that.
2464
2620
  */
2465
2621
  GetDeniedUpdateFields(user) {
2466
- return this.getDeniedFields(user, (p) => !p.CanUpdate);
2622
+ return this.getCachedDeniedFields(user, 'update', (p) => !p.CanUpdate);
2467
2623
  }
2468
2624
  /**
2469
2625
  * The set of field names this user may NOT supply a value for when CREATING a record. Same
@@ -2473,7 +2629,49 @@ export class EntityInfo extends BaseInfo {
2473
2629
  * create-denied field is dropped and the column takes its default.
2474
2630
  */
2475
2631
  GetDeniedCreateFields(user) {
2476
- return this.getDeniedFields(user, (p) => !p.CanCreate);
2632
+ return this.getCachedDeniedFields(user, 'create', (p) => !p.CanCreate);
2633
+ }
2634
+ getCachedDeniedFields(user, verb, isDenied) {
2635
+ if (!this.EnableFieldLevelSecurity) {
2636
+ return new Set();
2637
+ }
2638
+ if (!user || typeof user !== 'object') {
2639
+ return this.getDeniedFields(user, isDenied);
2640
+ }
2641
+ let totalPerms = 0;
2642
+ for (let i = 0; i < this._fields.length; i++) {
2643
+ totalPerms += this._fields[i].FieldPermissions.length;
2644
+ }
2645
+ const rolesRef = user.UserRoles;
2646
+ const rolesLen = rolesRef?.length ?? 0;
2647
+ let byUser = _entityDeniedFieldsCache.get(this);
2648
+ if (!byUser) {
2649
+ byUser = new WeakMap();
2650
+ _entityDeniedFieldsCache.set(this, byUser);
2651
+ }
2652
+ let entry = byUser.get(user);
2653
+ if (!entry ||
2654
+ entry.fieldsRef !== this._fields ||
2655
+ entry.fieldsLen !== this._fields.length ||
2656
+ entry.totalPerms !== totalPerms ||
2657
+ entry.rolesRef !== rolesRef ||
2658
+ entry.rolesLen !== rolesLen) {
2659
+ entry = {
2660
+ fieldsRef: this._fields,
2661
+ fieldsLen: this._fields.length,
2662
+ totalPerms,
2663
+ rolesRef,
2664
+ rolesLen,
2665
+ };
2666
+ byUser.set(user, entry);
2667
+ }
2668
+ const existing = entry[verb];
2669
+ if (existing !== undefined) {
2670
+ return existing;
2671
+ }
2672
+ const computed = this.getDeniedFields(user, isDenied);
2673
+ entry[verb] = computed;
2674
+ return computed;
2477
2675
  }
2478
2676
  /**
2479
2677
  * Shared walk behind {@link GetDeniedReadFields} / {@link GetDeniedUpdateFields} /
@@ -2528,8 +2726,9 @@ export class EntityInfo extends BaseInfo {
2528
2726
  if (name == null)
2529
2727
  return undefined;
2530
2728
  if (this._fieldByNameMap === null) {
2729
+ const fields = this._fields ?? this.Fields ?? [];
2531
2730
  const map = new Map();
2532
- for (const f of this._fields) {
2731
+ for (const f of fields) {
2533
2732
  if (f.Name != null)
2534
2733
  map.set(f.Name.trim().toLowerCase(), f);
2535
2734
  }
@@ -2537,6 +2736,62 @@ export class EntityInfo extends BaseInfo {
2537
2736
  }
2538
2737
  return this._fieldByNameMap.get(name.trim().toLowerCase());
2539
2738
  }
2739
+ ensureFieldIndexEntry() {
2740
+ const fields = this._fields ?? this.Fields ?? [];
2741
+ let entry = _entityFieldIndexCache.get(this);
2742
+ if (!entry || entry.fieldsRef !== fields || entry.fieldsLen !== fields.length) {
2743
+ const byName = new Map();
2744
+ const byCodeName = new Map();
2745
+ for (let i = 0; i < fields.length; i++) {
2746
+ const f = fields[i];
2747
+ if (f.Name != null) {
2748
+ const nameKey = f.Name.trim().toLowerCase();
2749
+ if (!byName.has(nameKey)) {
2750
+ byName.set(nameKey, i);
2751
+ }
2752
+ const rawCodeName = f.CodeName ?? f.Name;
2753
+ const codeKey = rawCodeName.trim().toLowerCase();
2754
+ if (!byCodeName.has(codeKey)) {
2755
+ byCodeName.set(codeKey, i);
2756
+ }
2757
+ }
2758
+ }
2759
+ entry = { fieldsRef: fields, fieldsLen: fields.length, byName, byCodeName };
2760
+ _entityFieldIndexCache.set(this, entry);
2761
+ }
2762
+ return entry;
2763
+ }
2764
+ /**
2765
+ * O(1) case-insensitive lookup of a field's positional index in `Fields` by `Name`.
2766
+ * Shared across all `BaseEntity` instances of this `EntityInfo` so individual entity
2767
+ * instances do not need to allocate their own `Map<string, EntityField>` on hydration.
2768
+ */
2769
+ FieldIndexByName(name) {
2770
+ if (!name)
2771
+ return -1;
2772
+ return this.ensureFieldIndexEntry().byName.get(name.trim().toLowerCase()) ?? -1;
2773
+ }
2774
+ /**
2775
+ * O(1) case-insensitive lookup of a field's positional index in `Fields` by `CodeName`.
2776
+ */
2777
+ FieldIndexByCodeName(codeName) {
2778
+ if (!codeName)
2779
+ return -1;
2780
+ return this.ensureFieldIndexEntry().byCodeName.get(codeName.trim().toLowerCase()) ?? -1;
2781
+ }
2782
+ /**
2783
+ * O(1) case-insensitive field lookup by `CodeName`. Uses the shared `_entityFieldIndexCache`
2784
+ * index so GraphQL resolvers and mutation handlers can resolve client `CodeName` keys without
2785
+ * linear `Fields.find(...)` scans.
2786
+ *
2787
+ * @param codeName field CodeName (matched case-insensitively, whitespace-trimmed)
2788
+ * @returns the matching EntityFieldInfo, or undefined if not found
2789
+ */
2790
+ FieldByCodeName(codeName) {
2791
+ const idx = this.FieldIndexByCodeName(codeName);
2792
+ const fields = this._fields ?? this.Fields;
2793
+ return idx >= 0 && fields ? fields[idx] : undefined;
2794
+ }
2540
2795
  /**
2541
2796
  * Returns the primary key field for the entity. For entities with a composite primary key, use the PrimaryKeys property which returns all.
2542
2797
  * In the case of a composite primary key, the PrimaryKey property will return the first field in the sequence of the primary key fields.
@@ -2859,7 +3114,7 @@ export class EntityInfo extends BaseInfo {
2859
3114
  get ParentEntityInfo() {
2860
3115
  if (!this.ParentID)
2861
3116
  return null;
2862
- const p = Metadata.Provider; // global-provider-ok: stateless info class — proxies to global metadata
3117
+ const p = MetadataProviderFor(this);
2863
3118
  if (p?.EntityByID) {
2864
3119
  return p.EntityByID(this.ParentID) ?? null;
2865
3120
  }
@@ -2875,7 +3130,23 @@ export class EntityInfo extends BaseInfo {
2875
3130
  * only one child type is allowed per parent record (disjoint subtypes).
2876
3131
  */
2877
3132
  get ChildEntities() {
2878
- return Metadata.Provider?.Entities?.filter(e => UUIDsEqual(e.ParentID, this.ID)) ?? []; // global-provider-ok: stateless info class — proxies to global metadata
3133
+ const provider = MetadataProviderFor(this);
3134
+ const entities = provider?.Entities;
3135
+ if (!entities || entities.length === 0) {
3136
+ return [];
3137
+ }
3138
+ const cached = _entityChildEntitiesCache.get(this);
3139
+ if (cached && cached.provider === provider && cached.entitiesRef === entities && cached.entitiesLen === entities.length) {
3140
+ return cached.children;
3141
+ }
3142
+ const children = entities.filter(e => UUIDsEqual(e.ParentID, this.ID));
3143
+ _entityChildEntitiesCache.set(this, {
3144
+ provider,
3145
+ entitiesRef: entities,
3146
+ entitiesLen: entities.length,
3147
+ children,
3148
+ });
3149
+ return children;
2879
3150
  }
2880
3151
  /**
2881
3152
  * Convenience alias: returns true when this entity is a parent type that allows
@@ -3048,23 +3319,21 @@ export class EntityInfo extends BaseInfo {
3048
3319
  */
3049
3320
  GetUserPermisions(user) {
3050
3321
  try {
3051
- const permissionList = [];
3052
- for (let j = 0; j < this.Permissions.length; j++) {
3053
- const ep = this.Permissions[j];
3054
- const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
3055
- if (roleMatch) // user has this role
3056
- permissionList.push(ep);
3057
- }
3058
- // Aggregate Allow and Deny separately, then subtract Deny from Allow per action.
3322
+ const _ = user.UserRoles; // preserve throw-on-null behavior
3323
+ const userRoleIds = getUserRoleIdSet(user);
3059
3324
  const allow = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
3060
3325
  const deny = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
3061
- for (const ep of permissionList) {
3062
- const isDeny = ep.IsDeny;
3063
- const bucket = isDeny ? deny : allow;
3064
- bucket.CanCreate = bucket.CanCreate || !!ep.CanCreate;
3065
- bucket.CanRead = bucket.CanRead || !!ep.CanRead;
3066
- bucket.CanUpdate = bucket.CanUpdate || !!ep.CanUpdate;
3067
- bucket.CanDelete = bucket.CanDelete || !!ep.CanDelete;
3326
+ if (userRoleIds.size > 0) {
3327
+ for (let j = 0; j < this.Permissions.length; j++) {
3328
+ const ep = this.Permissions[j];
3329
+ if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) {
3330
+ const bucket = ep.IsDeny ? deny : allow;
3331
+ bucket.CanCreate = bucket.CanCreate || !!ep.CanCreate;
3332
+ bucket.CanRead = bucket.CanRead || !!ep.CanRead;
3333
+ bucket.CanUpdate = bucket.CanUpdate || !!ep.CanUpdate;
3334
+ bucket.CanDelete = bucket.CanDelete || !!ep.CanDelete;
3335
+ }
3336
+ }
3068
3337
  }
3069
3338
  // An API-driven action also requires that the entity ALLOWS that action at all
3070
3339
  // (`Allow*API`). No role grant can create/update/delete records of an entity whose
@@ -3099,13 +3368,17 @@ export class EntityInfo extends BaseInfo {
3099
3368
  * @returns
3100
3369
  */
3101
3370
  UserExemptFromRowLevelSecurity(user, type) {
3371
+ const _ = user.UserRoles; // preserve throw-on-null behavior if user is null/undefined
3372
+ const userRoleIds = getUserRoleIdSet(user);
3373
+ if (userRoleIds.size === 0) {
3374
+ return false;
3375
+ }
3102
3376
  for (let j = 0; j < this.Permissions.length; j++) {
3103
3377
  const ep = this.Permissions[j];
3104
3378
  if (ep.IsDeny) {
3105
3379
  continue; // a Deny row's Can* flags are denials, never grants — it cannot exempt anyone
3106
3380
  }
3107
- const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
3108
- if (roleMatch) { // user has this role
3381
+ if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) { // user has this role
3109
3382
  switch (type) {
3110
3383
  case EntityPermissionType.Create:
3111
3384
  if (ep.CanCreate && !ep.CreateRLSFilterID)
@@ -3146,14 +3419,18 @@ export class EntityInfo extends BaseInfo {
3146
3419
  * @returns
3147
3420
  */
3148
3421
  GetUserRowLevelSecurityInfo(user, type) {
3422
+ const _ = user.UserRoles; // preserve throw-on-null behavior if user is null/undefined
3149
3423
  const rlsList = [];
3424
+ const userRoleIds = getUserRoleIdSet(user);
3425
+ if (userRoleIds.size === 0) {
3426
+ return rlsList;
3427
+ }
3150
3428
  for (let j = 0; j < this.Permissions.length; j++) {
3151
3429
  const ep = this.Permissions[j];
3152
3430
  if (ep.IsDeny) {
3153
3431
  continue; // never a grant — see the doc comment
3154
3432
  }
3155
- const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
3156
- if (roleMatch) { // user has this role
3433
+ if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) { // user has this role
3157
3434
  let matchObject = null;
3158
3435
  switch (type) {
3159
3436
  case EntityPermissionType.Create:
@@ -3269,7 +3546,7 @@ export class EntityInfo extends BaseInfo {
3269
3546
  const clauses = [...mine]
3270
3547
  .sort((a, b) => a.FilterID.localeCompare(b.FilterID)) // deterministic order for the cache fingerprint (INV-2)
3271
3548
  .map(b => {
3272
- const filter = Metadata.Provider.RowLevelSecurityFilters.find(f => UUIDsEqual(f.ID, b.FilterID)); // global-provider-ok: same resolution path as RLSFilter() above — stateless info class proxying to global metadata
3549
+ const filter = MetadataProviderFor(this)?.RowLevelSecurityFilters?.find(f => UUIDsEqual(f.ID, b.FilterID));
3273
3550
  if (!filter) {
3274
3551
  LogError(`API-key row filter ${b.FilterID} for entity ${this.Name} not found in metadata — resolving to (1=0)`);
3275
3552
  return '(1=0)';
@@ -3441,7 +3718,7 @@ export class EntityInfo extends BaseInfo {
3441
3718
  const conditions = [];
3442
3719
  // Resolve the spoke entity's own organic key (matching by Name) to pull its
3443
3720
  // per-column normalization. Falls back to the hub's expression if not found.
3444
- const spokeOrganicKey = EntityInfo.resolveSpokeOrganicKey(relatedEntity, organicKey);
3721
+ const spokeOrganicKey = EntityInfo.resolveSpokeOrganicKey(relatedEntity, organicKey, record.EntityInfo?.OwningProvider);
3445
3722
  for (let i = 0; i < matchFields.length; i++) {
3446
3723
  const value = record.Get(matchFields[i]);
3447
3724
  if (value == null) {
@@ -3565,7 +3842,20 @@ export class EntityInfo extends BaseInfo {
3565
3842
  default: return `'${escapedValue}'`;
3566
3843
  }
3567
3844
  }
3568
- constructor(initData = null) {
3845
+ static { this.CHILD_INIT_KEYS = new Set([
3846
+ 'EntityFields', '_Fields', 'Fields',
3847
+ 'EntityPermissions', '_Permissions', 'Permissions',
3848
+ 'EntitySettings', '_Settings', 'Settings',
3849
+ 'EntityRelationships', '_RelatedEntities', 'RelatedEntities',
3850
+ 'EntityOrganicKeys', '_OrganicKeys', 'OrganicKeys',
3851
+ ]); }
3852
+ /**
3853
+ * @param initData - the raw entity row (or another EntityInfo being re-instantiated)
3854
+ * @param md - the provider that owns this metadata. Sibling lookups ({@link ParentEntityInfo},
3855
+ * {@link ChildEntities}, RLS filters) resolve through it rather than the global provider, so
3856
+ * they stay correct in a multi-provider process. Optional for backward compatibility.
3857
+ */
3858
+ constructor(initData = null, md) {
3569
3859
  super();
3570
3860
  /**
3571
3861
  * Unique identifier for the entity
@@ -4045,8 +4335,9 @@ export class EntityInfo extends BaseInfo {
4045
4335
  this._parentChainCache = null;
4046
4336
  // Cache for ParentEntityFieldNames
4047
4337
  this._parentEntityFieldNamesCache = null;
4338
+ this.SetOwningProvider(md);
4048
4339
  if (initData) {
4049
- this.copyInitData(initData);
4340
+ this.copyInitData(initData, EntityInfo.CHILD_INIT_KEYS);
4050
4341
  // do some special handling to create class instances instead of just data objects
4051
4342
  // copy the Entity Fields (accept EntityFields, _Fields, or Fields as input names)
4052
4343
  this._fields = [];
@@ -4077,7 +4368,9 @@ export class EntityInfo extends BaseInfo {
4077
4368
  const ef = initData.EntityFields || initData._Fields || initData.Fields;
4078
4369
  if (ef) {
4079
4370
  for (let j = 0; j < ef.length; j++) {
4080
- this._fields.push(new EntityFieldInfo(ef[j]));
4371
+ const field = new EntityFieldInfo(ef[j]);
4372
+ field.SetOwningProvider(md);
4373
+ this._fields.push(field);
4081
4374
  }
4082
4375
  }
4083
4376
  // copy the Entity Permissions
@@ -4085,7 +4378,7 @@ export class EntityInfo extends BaseInfo {
4085
4378
  const ep = initData.EntityPermissions || initData._Permissions || initData.Permissions;
4086
4379
  if (ep) {
4087
4380
  for (let j = 0; j < ep.length; j++) {
4088
- this._permissions.push(new EntityPermissionInfo(ep[j]));
4381
+ this._permissions.push(new EntityPermissionInfo(ep[j], md));
4089
4382
  }
4090
4383
  }
4091
4384
  // copy the Entity settings