@memberjunction/core 6.2.0-edge.3 → 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 (99) 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 +33 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/RegisterForStartup.d.ts +44 -0
  8. package/dist/generic/RegisterForStartup.d.ts.map +1 -1
  9. package/dist/generic/RegisterForStartup.js +124 -48
  10. package/dist/generic/RegisterForStartup.js.map +1 -1
  11. package/dist/generic/baseEngine.d.ts +222 -7
  12. package/dist/generic/baseEngine.d.ts.map +1 -1
  13. package/dist/generic/baseEngine.js +555 -37
  14. package/dist/generic/baseEngine.js.map +1 -1
  15. package/dist/generic/baseEngineSweeper.d.ts +35 -0
  16. package/dist/generic/baseEngineSweeper.d.ts.map +1 -0
  17. package/dist/generic/baseEngineSweeper.js +97 -0
  18. package/dist/generic/baseEngineSweeper.js.map +1 -0
  19. package/dist/generic/baseEntity.d.ts +45 -1
  20. package/dist/generic/baseEntity.d.ts.map +1 -1
  21. package/dist/generic/baseEntity.js +308 -187
  22. package/dist/generic/baseEntity.js.map +1 -1
  23. package/dist/generic/baseInfo.d.ts +31 -3
  24. package/dist/generic/baseInfo.d.ts.map +1 -1
  25. package/dist/generic/baseInfo.js +138 -40
  26. package/dist/generic/baseInfo.js.map +1 -1
  27. package/dist/generic/databaseProviderBase.d.ts +57 -19
  28. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  29. package/dist/generic/databaseProviderBase.js +203 -80
  30. package/dist/generic/databaseProviderBase.js.map +1 -1
  31. package/dist/generic/entityConfiguration.d.ts +8 -2
  32. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  33. package/dist/generic/entityConfiguration.js +9 -0
  34. package/dist/generic/entityConfiguration.js.map +1 -1
  35. package/dist/generic/entityEventBatch.d.ts +124 -0
  36. package/dist/generic/entityEventBatch.d.ts.map +1 -0
  37. package/dist/generic/entityEventBatch.js +194 -0
  38. package/dist/generic/entityEventBatch.js.map +1 -0
  39. package/dist/generic/entityInfo.d.ts +103 -8
  40. package/dist/generic/entityInfo.d.ts.map +1 -1
  41. package/dist/generic/entityInfo.js +376 -60
  42. package/dist/generic/entityInfo.js.map +1 -1
  43. package/dist/generic/infoProviderResolution.d.ts +16 -0
  44. package/dist/generic/infoProviderResolution.d.ts.map +1 -0
  45. package/dist/generic/infoProviderResolution.js +17 -0
  46. package/dist/generic/infoProviderResolution.js.map +1 -0
  47. package/dist/generic/interfaces.d.ts +143 -3
  48. package/dist/generic/interfaces.d.ts.map +1 -1
  49. package/dist/generic/interfaces.js.map +1 -1
  50. package/dist/generic/localCacheManager.d.ts +505 -56
  51. package/dist/generic/localCacheManager.d.ts.map +1 -1
  52. package/dist/generic/localCacheManager.js +1185 -175
  53. package/dist/generic/localCacheManager.js.map +1 -1
  54. package/dist/generic/logging.d.ts +15 -0
  55. package/dist/generic/logging.d.ts.map +1 -1
  56. package/dist/generic/logging.js +17 -0
  57. package/dist/generic/logging.js.map +1 -1
  58. package/dist/generic/metadata.d.ts +1 -1
  59. package/dist/generic/metadata.d.ts.map +1 -1
  60. package/dist/generic/metadata.js.map +1 -1
  61. package/dist/generic/mjapiUrl.d.ts +21 -0
  62. package/dist/generic/mjapiUrl.d.ts.map +1 -0
  63. package/dist/generic/mjapiUrl.js +25 -0
  64. package/dist/generic/mjapiUrl.js.map +1 -0
  65. package/dist/generic/providerBase.d.ts +301 -5
  66. package/dist/generic/providerBase.d.ts.map +1 -1
  67. package/dist/generic/providerBase.js +750 -168
  68. package/dist/generic/providerBase.js.map +1 -1
  69. package/dist/generic/queryInfo.d.ts +10 -9
  70. package/dist/generic/queryInfo.d.ts.map +1 -1
  71. package/dist/generic/queryInfo.js +60 -33
  72. package/dist/generic/queryInfo.js.map +1 -1
  73. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  74. package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
  75. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  76. package/dist/generic/securityInfo.d.ts +11 -6
  77. package/dist/generic/securityInfo.d.ts.map +1 -1
  78. package/dist/generic/securityInfo.js +15 -10
  79. package/dist/generic/securityInfo.js.map +1 -1
  80. package/dist/generic/telemetryManager.d.ts.map +1 -1
  81. package/dist/generic/telemetryManager.js +4 -1
  82. package/dist/generic/telemetryManager.js.map +1 -1
  83. package/dist/generic/transactionGroup.d.ts +27 -0
  84. package/dist/generic/transactionGroup.d.ts.map +1 -1
  85. package/dist/generic/transactionGroup.js +58 -5
  86. package/dist/generic/transactionGroup.js.map +1 -1
  87. package/dist/generic/util.d.ts.map +1 -1
  88. package/dist/generic/util.js +3 -0
  89. package/dist/generic/util.js.map +1 -1
  90. package/dist/generic/wellKnownUserSource.d.ts +3 -2
  91. package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
  92. package/dist/generic/wellKnownUserSource.js +18 -9
  93. package/dist/generic/wellKnownUserSource.js.map +1 -1
  94. package/dist/index.d.ts +2 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +2 -0
  97. package/dist/index.js.map +1 -1
  98. package/package.json +3 -3
  99. 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
+ }
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;
2099
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
  */
@@ -2139,6 +2271,11 @@ export class EntitySettingInfo extends BaseInfo {
2139
2271
  * Catalog of all entities across all schemas.
2140
2272
  * Contains comprehensive metadata about each entity including its database mappings, security settings, and UI preferences.
2141
2273
  */
2274
+ /** Backing list for {@link EntityInfo.ConfigurableAtCreationAttributes}. */
2275
+ const ENTITY_CONFIGURABLE_AT_CREATION_ATTRIBUTES = [
2276
+ 'IncludeInAPI', 'AllowUserSearchAPI', 'AllowCaching', 'TrackRecordChanges', 'AuditRecordAccess',
2277
+ 'AuditViewRuns', 'AllowAllRowsAPI', 'AllowCreateAPI', 'AllowUpdateAPI', 'AllowDeleteAPI', 'UserViewMaxRows',
2278
+ ];
2142
2279
  export class EntityInfo extends BaseInfo {
2143
2280
  /**
2144
2281
  * The view CodeGen actually WRITES for this entity.
@@ -2322,6 +2459,30 @@ export class EntityInfo extends BaseInfo {
2322
2459
  get CloneConfig() {
2323
2460
  return this.ConfigurationObject?.Clone ?? null;
2324
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
+ }
2325
2486
  /**
2326
2487
  * Returns true if cloning is explicitly enabled for this entity as a ROOT record.
2327
2488
  */
@@ -2389,7 +2550,7 @@ export class EntityInfo extends BaseInfo {
2389
2550
  * `size === 0` as the single "nothing to do" condition.
2390
2551
  */
2391
2552
  GetDeniedReadFields(user) {
2392
- return this.getDeniedFields(user, (p) => !p.CanRead);
2553
+ return this.getCachedDeniedFields(user, 'read', (p) => !p.CanRead);
2393
2554
  }
2394
2555
  /**
2395
2556
  * Whether this user may READ the named field — the single-field question, answered the same
@@ -2458,7 +2619,7 @@ export class EntityInfo extends BaseInfo {
2458
2619
  * actually need rather than relying on that.
2459
2620
  */
2460
2621
  GetDeniedUpdateFields(user) {
2461
- return this.getDeniedFields(user, (p) => !p.CanUpdate);
2622
+ return this.getCachedDeniedFields(user, 'update', (p) => !p.CanUpdate);
2462
2623
  }
2463
2624
  /**
2464
2625
  * The set of field names this user may NOT supply a value for when CREATING a record. Same
@@ -2468,7 +2629,49 @@ export class EntityInfo extends BaseInfo {
2468
2629
  * create-denied field is dropped and the column takes its default.
2469
2630
  */
2470
2631
  GetDeniedCreateFields(user) {
2471
- 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;
2472
2675
  }
2473
2676
  /**
2474
2677
  * Shared walk behind {@link GetDeniedReadFields} / {@link GetDeniedUpdateFields} /
@@ -2523,8 +2726,9 @@ export class EntityInfo extends BaseInfo {
2523
2726
  if (name == null)
2524
2727
  return undefined;
2525
2728
  if (this._fieldByNameMap === null) {
2729
+ const fields = this._fields ?? this.Fields ?? [];
2526
2730
  const map = new Map();
2527
- for (const f of this._fields) {
2731
+ for (const f of fields) {
2528
2732
  if (f.Name != null)
2529
2733
  map.set(f.Name.trim().toLowerCase(), f);
2530
2734
  }
@@ -2532,6 +2736,62 @@ export class EntityInfo extends BaseInfo {
2532
2736
  }
2533
2737
  return this._fieldByNameMap.get(name.trim().toLowerCase());
2534
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
+ }
2535
2795
  /**
2536
2796
  * Returns the primary key field for the entity. For entities with a composite primary key, use the PrimaryKeys property which returns all.
2537
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.
@@ -2731,6 +2991,14 @@ export class EntityInfo extends BaseInfo {
2731
2991
  static get DeletedAtFieldName() {
2732
2992
  return EntityInfo.__deletedAtFieldName;
2733
2993
  }
2994
+ /**
2995
+ * Entity flags that a per-table entry in CodeGen's additionalSchemaInfo config may set when CodeGen first
2996
+ * creates the entity, overriding the global `newEntityDefaults`. Typed against `EntityInfo`, so a name that
2997
+ * stops being an entity property fails to compile.
2998
+ */
2999
+ static get ConfigurableAtCreationAttributes() {
3000
+ return ENTITY_CONFIGURABLE_AT_CREATION_ATTRIBUTES;
3001
+ }
2734
3002
  /**
2735
3003
  * This static factory method is used to check to see if the entity in question is active or not
2736
3004
  * If it is not active, it will throw an exception or log a warning depending on the status of the entity being
@@ -2846,7 +3114,7 @@ export class EntityInfo extends BaseInfo {
2846
3114
  get ParentEntityInfo() {
2847
3115
  if (!this.ParentID)
2848
3116
  return null;
2849
- const p = Metadata.Provider; // global-provider-ok: stateless info class — proxies to global metadata
3117
+ const p = MetadataProviderFor(this);
2850
3118
  if (p?.EntityByID) {
2851
3119
  return p.EntityByID(this.ParentID) ?? null;
2852
3120
  }
@@ -2862,7 +3130,23 @@ export class EntityInfo extends BaseInfo {
2862
3130
  * only one child type is allowed per parent record (disjoint subtypes).
2863
3131
  */
2864
3132
  get ChildEntities() {
2865
- 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;
2866
3150
  }
2867
3151
  /**
2868
3152
  * Convenience alias: returns true when this entity is a parent type that allows
@@ -3035,23 +3319,21 @@ export class EntityInfo extends BaseInfo {
3035
3319
  */
3036
3320
  GetUserPermisions(user) {
3037
3321
  try {
3038
- const permissionList = [];
3039
- for (let j = 0; j < this.Permissions.length; j++) {
3040
- const ep = this.Permissions[j];
3041
- const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
3042
- if (roleMatch) // user has this role
3043
- permissionList.push(ep);
3044
- }
3045
- // 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);
3046
3324
  const allow = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
3047
3325
  const deny = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
3048
- for (const ep of permissionList) {
3049
- const isDeny = ep.IsDeny;
3050
- const bucket = isDeny ? deny : allow;
3051
- bucket.CanCreate = bucket.CanCreate || !!ep.CanCreate;
3052
- bucket.CanRead = bucket.CanRead || !!ep.CanRead;
3053
- bucket.CanUpdate = bucket.CanUpdate || !!ep.CanUpdate;
3054
- 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
+ }
3055
3337
  }
3056
3338
  // An API-driven action also requires that the entity ALLOWS that action at all
3057
3339
  // (`Allow*API`). No role grant can create/update/delete records of an entity whose
@@ -3086,13 +3368,17 @@ export class EntityInfo extends BaseInfo {
3086
3368
  * @returns
3087
3369
  */
3088
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
+ }
3089
3376
  for (let j = 0; j < this.Permissions.length; j++) {
3090
3377
  const ep = this.Permissions[j];
3091
3378
  if (ep.IsDeny) {
3092
3379
  continue; // a Deny row's Can* flags are denials, never grants — it cannot exempt anyone
3093
3380
  }
3094
- const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
3095
- if (roleMatch) { // user has this role
3381
+ if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) { // user has this role
3096
3382
  switch (type) {
3097
3383
  case EntityPermissionType.Create:
3098
3384
  if (ep.CanCreate && !ep.CreateRLSFilterID)
@@ -3133,14 +3419,18 @@ export class EntityInfo extends BaseInfo {
3133
3419
  * @returns
3134
3420
  */
3135
3421
  GetUserRowLevelSecurityInfo(user, type) {
3422
+ const _ = user.UserRoles; // preserve throw-on-null behavior if user is null/undefined
3136
3423
  const rlsList = [];
3424
+ const userRoleIds = getUserRoleIdSet(user);
3425
+ if (userRoleIds.size === 0) {
3426
+ return rlsList;
3427
+ }
3137
3428
  for (let j = 0; j < this.Permissions.length; j++) {
3138
3429
  const ep = this.Permissions[j];
3139
3430
  if (ep.IsDeny) {
3140
3431
  continue; // never a grant — see the doc comment
3141
3432
  }
3142
- const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
3143
- if (roleMatch) { // user has this role
3433
+ if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) { // user has this role
3144
3434
  let matchObject = null;
3145
3435
  switch (type) {
3146
3436
  case EntityPermissionType.Create:
@@ -3256,7 +3546,7 @@ export class EntityInfo extends BaseInfo {
3256
3546
  const clauses = [...mine]
3257
3547
  .sort((a, b) => a.FilterID.localeCompare(b.FilterID)) // deterministic order for the cache fingerprint (INV-2)
3258
3548
  .map(b => {
3259
- 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));
3260
3550
  if (!filter) {
3261
3551
  LogError(`API-key row filter ${b.FilterID} for entity ${this.Name} not found in metadata — resolving to (1=0)`);
3262
3552
  return '(1=0)';
@@ -3428,7 +3718,7 @@ export class EntityInfo extends BaseInfo {
3428
3718
  const conditions = [];
3429
3719
  // Resolve the spoke entity's own organic key (matching by Name) to pull its
3430
3720
  // per-column normalization. Falls back to the hub's expression if not found.
3431
- const spokeOrganicKey = EntityInfo.resolveSpokeOrganicKey(relatedEntity, organicKey);
3721
+ const spokeOrganicKey = EntityInfo.resolveSpokeOrganicKey(relatedEntity, organicKey, record.EntityInfo?.OwningProvider);
3432
3722
  for (let i = 0; i < matchFields.length; i++) {
3433
3723
  const value = record.Get(matchFields[i]);
3434
3724
  if (value == null) {
@@ -3552,7 +3842,20 @@ export class EntityInfo extends BaseInfo {
3552
3842
  default: return `'${escapedValue}'`;
3553
3843
  }
3554
3844
  }
3555
- 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) {
3556
3859
  super();
3557
3860
  /**
3558
3861
  * Unique identifier for the entity
@@ -3677,11 +3980,21 @@ export class EntityInfo extends BaseInfo {
3677
3980
  */
3678
3981
  this.AuditViewRuns = null;
3679
3982
  /**
3680
- * When true (default), the server-side RunView cache will store and return cached results
3681
- * for this entity, trusting that all mutations flow through BaseEntity.Save() which fires
3682
- * cache invalidation events. Set to false for entities whose rows are created as side-effects
3683
- * of other operations via raw SQL (e.g., Record Changes created by spCreateRecordChange_Internal),
3684
- * since those inserts bypass BaseEntity and never trigger cache invalidation.
3983
+ * Whether every change to this entity's rows fires a `BaseEntity` event. True (the default)
3984
+ * declares that all mutations flow through `BaseEntity.Save()`/`Delete()`, so a cached copy
3985
+ * kept current by those events can be trusted. False declares that rows can change without an
3986
+ * event: raw SQL, a stored procedure (e.g. Record Changes created by
3987
+ * `spCreateRecordChange_Internal`), or another application writing the same table.
3988
+ *
3989
+ * Each cache layer responds to `false` in the way that suits it:
3990
+ * - **Server RunView cache:** does not cache the entity, since re-validating would cost as
3991
+ * much as running the query.
3992
+ * - **Client RunView cache:** caches as before; every read is validated against the database.
3993
+ * - **Engine caches (`BaseEngine`):** keep the rows, and the engine sweep re-checks them on its
3994
+ * interval. The user cache and the metadata sweep do the same for their entities.
3995
+ *
3996
+ * To keep an entity out of caching altogether, set {@link AllowCaching} to false instead.
3997
+ * Setting any `AllowDirectSQL*` flag requires this to be false (a database CHECK enforces it).
3685
3998
  */
3686
3999
  this.TrustServerCacheCompletely = true;
3687
4000
  /**
@@ -4022,8 +4335,9 @@ export class EntityInfo extends BaseInfo {
4022
4335
  this._parentChainCache = null;
4023
4336
  // Cache for ParentEntityFieldNames
4024
4337
  this._parentEntityFieldNamesCache = null;
4338
+ this.SetOwningProvider(md);
4025
4339
  if (initData) {
4026
- this.copyInitData(initData);
4340
+ this.copyInitData(initData, EntityInfo.CHILD_INIT_KEYS);
4027
4341
  // do some special handling to create class instances instead of just data objects
4028
4342
  // copy the Entity Fields (accept EntityFields, _Fields, or Fields as input names)
4029
4343
  this._fields = [];
@@ -4054,7 +4368,9 @@ export class EntityInfo extends BaseInfo {
4054
4368
  const ef = initData.EntityFields || initData._Fields || initData.Fields;
4055
4369
  if (ef) {
4056
4370
  for (let j = 0; j < ef.length; j++) {
4057
- this._fields.push(new EntityFieldInfo(ef[j]));
4371
+ const field = new EntityFieldInfo(ef[j]);
4372
+ field.SetOwningProvider(md);
4373
+ this._fields.push(field);
4058
4374
  }
4059
4375
  }
4060
4376
  // copy the Entity Permissions
@@ -4062,7 +4378,7 @@ export class EntityInfo extends BaseInfo {
4062
4378
  const ep = initData.EntityPermissions || initData._Permissions || initData.Permissions;
4063
4379
  if (ep) {
4064
4380
  for (let j = 0; j < ep.length; j++) {
4065
- this._permissions.push(new EntityPermissionInfo(ep[j]));
4381
+ this._permissions.push(new EntityPermissionInfo(ep[j], md));
4066
4382
  }
4067
4383
  }
4068
4384
  // copy the Entity settings