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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +22 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +43 -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/alternateBaseViews.d.ts +119 -0
  8. package/dist/generic/alternateBaseViews.d.ts.map +1 -0
  9. package/dist/generic/alternateBaseViews.js +223 -0
  10. package/dist/generic/alternateBaseViews.js.map +1 -0
  11. package/dist/generic/baseEngine.d.ts +82 -14
  12. package/dist/generic/baseEngine.d.ts.map +1 -1
  13. package/dist/generic/baseEngine.js +311 -46
  14. package/dist/generic/baseEngine.js.map +1 -1
  15. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  16. package/dist/generic/baseEngineRegistry.js +2 -1
  17. package/dist/generic/baseEngineRegistry.js.map +1 -1
  18. package/dist/generic/baseEntity.d.ts +194 -14
  19. package/dist/generic/baseEntity.d.ts.map +1 -1
  20. package/dist/generic/baseEntity.js +638 -218
  21. package/dist/generic/baseEntity.js.map +1 -1
  22. package/dist/generic/baseInfo.d.ts +31 -3
  23. package/dist/generic/baseInfo.d.ts.map +1 -1
  24. package/dist/generic/baseInfo.js +138 -40
  25. package/dist/generic/baseInfo.js.map +1 -1
  26. package/dist/generic/contextTokens.d.ts +136 -0
  27. package/dist/generic/contextTokens.d.ts.map +1 -0
  28. package/dist/generic/contextTokens.js +319 -0
  29. package/dist/generic/contextTokens.js.map +1 -0
  30. package/dist/generic/databaseProviderBase.d.ts +103 -13
  31. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  32. package/dist/generic/databaseProviderBase.js +321 -77
  33. package/dist/generic/databaseProviderBase.js.map +1 -1
  34. package/dist/generic/embeddedRecord.d.ts +6 -0
  35. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  36. package/dist/generic/embeddedRecord.js +15 -1
  37. package/dist/generic/embeddedRecord.js.map +1 -1
  38. package/dist/generic/entityCompanion.d.ts +7 -0
  39. package/dist/generic/entityCompanion.d.ts.map +1 -1
  40. package/dist/generic/entityCompanion.js +8 -0
  41. package/dist/generic/entityCompanion.js.map +1 -1
  42. package/dist/generic/entityConfiguration.d.ts +8 -2
  43. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  44. package/dist/generic/entityConfiguration.js +9 -0
  45. package/dist/generic/entityConfiguration.js.map +1 -1
  46. package/dist/generic/entityInfo.d.ts +306 -12
  47. package/dist/generic/entityInfo.d.ts.map +1 -1
  48. package/dist/generic/entityInfo.js +894 -92
  49. package/dist/generic/entityInfo.js.map +1 -1
  50. package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
  51. package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
  52. package/dist/generic/entityValueFieldSecurity.js +250 -0
  53. package/dist/generic/entityValueFieldSecurity.js.map +1 -0
  54. package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
  55. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
  56. package/dist/generic/fieldSecuritySQLGate.js +898 -0
  57. package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
  58. package/dist/generic/indexRestrictedFields.d.ts +47 -0
  59. package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
  60. package/dist/generic/indexRestrictedFields.js +129 -0
  61. package/dist/generic/indexRestrictedFields.js.map +1 -0
  62. package/dist/generic/infoProviderResolution.d.ts +16 -0
  63. package/dist/generic/infoProviderResolution.d.ts.map +1 -0
  64. package/dist/generic/infoProviderResolution.js +17 -0
  65. package/dist/generic/infoProviderResolution.js.map +1 -0
  66. package/dist/generic/interfaces.d.ts +39 -2
  67. package/dist/generic/interfaces.d.ts.map +1 -1
  68. package/dist/generic/interfaces.js.map +1 -1
  69. package/dist/generic/localCacheManager.d.ts +318 -51
  70. package/dist/generic/localCacheManager.d.ts.map +1 -1
  71. package/dist/generic/localCacheManager.js +675 -109
  72. package/dist/generic/localCacheManager.js.map +1 -1
  73. package/dist/generic/logging.d.ts.map +1 -1
  74. package/dist/generic/logging.js +7 -1
  75. package/dist/generic/logging.js.map +1 -1
  76. package/dist/generic/metadata.d.ts +3 -2
  77. package/dist/generic/metadata.d.ts.map +1 -1
  78. package/dist/generic/metadata.js +5 -2
  79. package/dist/generic/metadata.js.map +1 -1
  80. package/dist/generic/mjapiUrl.d.ts +21 -0
  81. package/dist/generic/mjapiUrl.d.ts.map +1 -0
  82. package/dist/generic/mjapiUrl.js +25 -0
  83. package/dist/generic/mjapiUrl.js.map +1 -0
  84. package/dist/generic/platformSQL.d.ts +54 -0
  85. package/dist/generic/platformSQL.d.ts.map +1 -1
  86. package/dist/generic/platformSQL.js +55 -0
  87. package/dist/generic/platformSQL.js.map +1 -1
  88. package/dist/generic/providerBase.d.ts +370 -57
  89. package/dist/generic/providerBase.d.ts.map +1 -1
  90. package/dist/generic/providerBase.js +1085 -284
  91. package/dist/generic/providerBase.js.map +1 -1
  92. package/dist/generic/queryInfo.d.ts +23 -9
  93. package/dist/generic/queryInfo.d.ts.map +1 -1
  94. package/dist/generic/queryInfo.js +73 -33
  95. package/dist/generic/queryInfo.js.map +1 -1
  96. package/dist/generic/queryInfoInterfaces.d.ts +8 -0
  97. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  98. package/dist/generic/queryTemplateContext.d.ts +49 -0
  99. package/dist/generic/queryTemplateContext.d.ts.map +1 -0
  100. package/dist/generic/queryTemplateContext.js +113 -0
  101. package/dist/generic/queryTemplateContext.js.map +1 -0
  102. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
  103. package/dist/generic/recordChangeFieldSecurity.js +13 -2
  104. package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
  105. package/dist/generic/relatedRecordCollection.d.ts +13 -0
  106. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  107. package/dist/generic/relatedRecordCollection.js +44 -5
  108. package/dist/generic/relatedRecordCollection.js.map +1 -1
  109. package/dist/generic/runQuery.d.ts +42 -0
  110. package/dist/generic/runQuery.d.ts.map +1 -1
  111. package/dist/generic/runQuery.js +14 -0
  112. package/dist/generic/runQuery.js.map +1 -1
  113. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  114. package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
  115. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  116. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  117. package/dist/generic/saveEntityGraphOperation.js +3 -0
  118. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  119. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  120. package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
  121. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  122. package/dist/generic/securityInfo.d.ts +17 -6
  123. package/dist/generic/securityInfo.d.ts.map +1 -1
  124. package/dist/generic/securityInfo.js +25 -10
  125. package/dist/generic/securityInfo.js.map +1 -1
  126. package/dist/generic/telemetryManager.d.ts +9 -0
  127. package/dist/generic/telemetryManager.d.ts.map +1 -1
  128. package/dist/generic/telemetryManager.js +5 -1
  129. package/dist/generic/telemetryManager.js.map +1 -1
  130. package/dist/generic/util.d.ts.map +1 -1
  131. package/dist/generic/util.js +3 -0
  132. package/dist/generic/util.js.map +1 -1
  133. package/dist/generic/wellKnownUserSource.d.ts +3 -2
  134. package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
  135. package/dist/generic/wellKnownUserSource.js +18 -9
  136. package/dist/generic/wellKnownUserSource.js.map +1 -1
  137. package/dist/index.d.ts +8 -0
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +8 -0
  140. package/dist/index.js.map +1 -1
  141. package/dist/views/runView.d.ts +89 -0
  142. package/dist/views/runView.d.ts.map +1 -1
  143. package/dist/views/runView.js +36 -0
  144. package/dist/views/runView.js.map +1 -1
  145. package/dist/views/viewProperties.d.ts +55 -0
  146. package/dist/views/viewProperties.d.ts.map +1 -0
  147. package/dist/views/viewProperties.js +258 -0
  148. package/dist/views/viewProperties.js.map +1 -0
  149. package/package.json +3 -3
  150. package/readme.md +29 -4
@@ -1,9 +1,10 @@
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";
6
- import { WarningManager, SafeJSONParse, UUIDsEqual, ordinalCompare, GeneratePluralName } from "@memberjunction/global";
7
+ import { WarningManager, SafeJSONParse, UUIDsEqual, NormalizeUUID, ordinalCompare, GeneratePluralName } from "@memberjunction/global";
7
8
  import { ReadRelationshipJoinFields, } from "./entityConfiguration.js";
8
9
  /**
9
10
  * Runtime domain for {@link EntityFieldInfo.ExtendedType}. This array is the single source of
@@ -305,6 +306,186 @@ export class EntityOrganicKeyRelatedEntityInfo extends BaseInfo {
305
306
  }
306
307
  }
307
308
  }
309
+ /**
310
+ * Metadata describing a governed alternate SQL view on an entity that maintains 100% column
311
+ * parity with the entity's canonical `BaseView` while applying a curated canonical filter
312
+ * or join (for example, `Active` members or `Open` orders).
313
+ *
314
+ * Alternate base views can form a same-entity derivation hierarchy via {@link ParentID}
315
+ * (for example, `ActiveAutoRenew` selecting from `vwActiveMembers`).
316
+ *
317
+ * Maps to the `MJ: Entity Alternate Base Views` metadata entity.
318
+ */
319
+ export class EntityAlternateBaseViewInfo extends BaseInfo {
320
+ /**
321
+ * The parent alternate base view on the same entity when {@link ParentID} is set,
322
+ * or `undefined` for a top-level alternate base view.
323
+ */
324
+ get ParentView() {
325
+ return this._resolvedParentViewRef;
326
+ }
327
+ /**
328
+ * Direct child alternate base views on the same entity that derive from this view via {@link ParentID}.
329
+ */
330
+ get ChildViews() {
331
+ return this._resolvedChildViewsList;
332
+ }
333
+ /**
334
+ * Effective database schema name for this alternate base view: uses {@link SchemaName}
335
+ * when explicitly populated, falling back to the parent entity's `SchemaName`.
336
+ */
337
+ get EffectiveSchemaName() {
338
+ const explicit = this.SchemaName?.trim();
339
+ if (explicit && explicit.length > 0) {
340
+ return explicit;
341
+ }
342
+ return this._entitySchemaName ?? '';
343
+ }
344
+ /**
345
+ * Human-friendly label for UI surfaces: returns {@link DisplayName} when set,
346
+ * falling back to {@link Name}.
347
+ */
348
+ get EffectiveDisplayName() {
349
+ const display = this.DisplayName?.trim();
350
+ if (display && display.length > 0) {
351
+ return display;
352
+ }
353
+ return this.Name;
354
+ }
355
+ /**
356
+ * True when this alternate base view (and every ancestor in its {@link ParentView} chain)
357
+ * has `Status` equal to `'Active'` or `'Deprecated'`. Disabled views (or views derived
358
+ * from a disabled or missing ancestor) return `false`.
359
+ */
360
+ get IsUsable() {
361
+ return this.checkIsUsable(new Set());
362
+ }
363
+ checkIsUsable(visited) {
364
+ if (this.Status !== 'Active' && this.Status !== 'Deprecated') {
365
+ return false;
366
+ }
367
+ if (!this.ParentID) {
368
+ return true;
369
+ }
370
+ if (!this._resolvedParentViewRef) {
371
+ return false; // ParentID is set but parent view was not found on the entity
372
+ }
373
+ const key = NormalizeUUID(this.ID);
374
+ if (key && visited.has(key)) {
375
+ return false; // Defensive cycle guard
376
+ }
377
+ if (key) {
378
+ visited.add(key);
379
+ }
380
+ return this._resolvedParentViewRef.checkIsUsable(visited);
381
+ }
382
+ /**
383
+ * True when this view (or any ancestor in its {@link ParentView} chain) has
384
+ * `Status === 'Deprecated'`. Deprecated alternate base views remain usable at runtime
385
+ * so existing views and queries do not break, but emit a deprecation warning.
386
+ */
387
+ get IsDeprecated() {
388
+ return this.checkIsDeprecated(new Set());
389
+ }
390
+ checkIsDeprecated(visited) {
391
+ if (this.Status === 'Deprecated') {
392
+ return true;
393
+ }
394
+ if (!this._resolvedParentViewRef) {
395
+ return false;
396
+ }
397
+ const key = NormalizeUUID(this.ID);
398
+ if (key && visited.has(key)) {
399
+ return false;
400
+ }
401
+ if (key) {
402
+ visited.add(key);
403
+ }
404
+ return this._resolvedParentViewRef.checkIsDeprecated(visited);
405
+ }
406
+ /**
407
+ * True when `CachePolicy === 'Bypass'` on this view or any ancestor in its {@link ParentView}
408
+ * chain, instructing `ProviderBase` to skip local/server result caching for reads through
409
+ * this alternate base view (used when the view reads the clock or joins other tables whose
410
+ * updates do not fire `BaseEntity` events on this entity).
411
+ */
412
+ get IsBypassCache() {
413
+ return this.checkIsBypassCache(new Set());
414
+ }
415
+ checkIsBypassCache(visited) {
416
+ if (this.CachePolicy === 'Bypass') {
417
+ return true;
418
+ }
419
+ if (!this._resolvedParentViewRef) {
420
+ return false;
421
+ }
422
+ const key = NormalizeUUID(this.ID);
423
+ if (key && visited.has(key)) {
424
+ return false;
425
+ }
426
+ if (key) {
427
+ visited.add(key);
428
+ }
429
+ return this._resolvedParentViewRef.checkIsBypassCache(visited);
430
+ }
431
+ /**
432
+ * Links `ParentView` and `ChildViews` references across the alternate base views belonging
433
+ * to a single entity and stamps the owning entity's `SchemaName` fallback.
434
+ */
435
+ static LinkHierarchy(views, entitySchemaName) {
436
+ const byId = new Map();
437
+ for (const v of views) {
438
+ if (entitySchemaName !== undefined) {
439
+ v._entitySchemaName = entitySchemaName;
440
+ }
441
+ v._resolvedParentViewRef = undefined;
442
+ v._resolvedChildViewsList = [];
443
+ if (v.ID) {
444
+ byId.set(NormalizeUUID(v.ID), v);
445
+ }
446
+ }
447
+ for (const v of views) {
448
+ if (v.ParentID && !UUIDsEqual(v.ParentID, v.ID)) {
449
+ const parent = byId.get(NormalizeUUID(v.ParentID));
450
+ if (parent) {
451
+ v._resolvedParentViewRef = parent;
452
+ parent._resolvedChildViewsList.push(v);
453
+ }
454
+ }
455
+ }
456
+ }
457
+ constructor(initData = null, entitySchemaName) {
458
+ super();
459
+ this.ID = null;
460
+ this.EntityID = null;
461
+ this.ParentID = null;
462
+ this.Name = null;
463
+ this.DisplayName = null;
464
+ this.Description = null;
465
+ this.SchemaName = null;
466
+ this.ViewName = null;
467
+ this.CachePolicy = 'Default';
468
+ this.Status = 'Active';
469
+ this.Sequence = 0;
470
+ this.__mj_CreatedAt = null;
471
+ this.__mj_UpdatedAt = null;
472
+ // Virtual fields from vwEntityAlternateBaseViews
473
+ this.Entity = null;
474
+ this.Parent = null;
475
+ this.RootParentID = null;
476
+ this._entitySchemaName = null;
477
+ // Named without matching `ParentView` / `ChildViews` getter names so `BaseInfo.toJSON()`
478
+ // does not serialize parent<->child object references into a circular JSON graph.
479
+ this._resolvedParentViewRef = undefined;
480
+ this._resolvedChildViewsList = [];
481
+ if (entitySchemaName !== undefined) {
482
+ this._entitySchemaName = entitySchemaName;
483
+ }
484
+ if (initData) {
485
+ this.copyInitData(initData);
486
+ }
487
+ }
488
+ }
308
489
  export const EntityPermissionType = {
309
490
  Read: 'Read',
310
491
  Create: 'Create',
@@ -369,9 +550,13 @@ export class EntityPermissionInfo extends BaseInfo {
369
550
  break;
370
551
  }
371
552
  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
553
+ return MetadataProviderFor(this)?.RowLevelSecurityFilters?.find(f => UUIDsEqual(f.ID, fID));
373
554
  }
374
- constructor(initData) {
555
+ /**
556
+ * @param initData - the raw permission row
557
+ * @param md - the provider that owns this metadata; RLS filters resolve through it (see {@link BaseInfo.OwningProvider})
558
+ */
559
+ constructor(initData, md) {
375
560
  super();
376
561
  this.ID = null;
377
562
  this.EntityID = null;
@@ -401,6 +586,7 @@ export class EntityPermissionInfo extends BaseInfo {
401
586
  this.CreateRLSFilter = null;
402
587
  this.UpdateRLSFilter = null;
403
588
  this.DeleteRLSFilter = null;
589
+ this.SetOwningProvider(md);
404
590
  this.copyInitData(initData);
405
591
  }
406
592
  }
@@ -457,9 +643,16 @@ export const ReadableFieldsTransportKey = 'ReadableFields___';
457
643
  * set and aggregate it — see {@link EntityFieldInfo.AggregateFieldRulesForUser}.
458
644
  */
459
645
  export function IsRestrictingFieldRule(rule) {
460
- return (rule?.ReadAccess === FieldPermissionAccess.Deny ||
461
- rule?.UpdateAccess === FieldPermissionAccess.Deny ||
462
- rule?.CreateAccess === FieldPermissionAccess.Deny);
646
+ return isDenyAccess(rule?.ReadAccess) || isDenyAccess(rule?.UpdateAccess) || isDenyAccess(rule?.CreateAccess);
647
+ }
648
+ /**
649
+ * Whether one Access value means `Deny`, compared the way the aggregation compares: ignoring case
650
+ * and surrounding whitespace. The column's CHECK constraint runs under a case-insensitive
651
+ * collation, so `'deny'` stores, and the aggregation enforces it as a Deny; a strict `===` here
652
+ * would call that same rule harmless.
653
+ */
654
+ function isDenyAccess(access) {
655
+ return (access ?? '').trim().toLowerCase() === 'deny';
463
656
  }
464
657
  /**
465
658
  * The single wording for "you cannot use this field", modelled on SQL Server's posture of never
@@ -927,14 +1120,16 @@ export class EntityFieldInfo extends BaseInfo {
927
1120
  static AggregateFieldRulesForUser(rules, user) {
928
1121
  const allow = { CanRead: false, CanUpdate: false, CanCreate: false };
929
1122
  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
1123
+ const userRoleIds = getUserRoleIdSet(user);
1124
+ if (userRoleIds.size > 0) {
1125
+ for (const fp of rules) {
1126
+ if (!fp.RoleID || !userRoleIds.has(fp.RoleID.trim().toLowerCase())) {
1127
+ continue; // user does not hold this role
1128
+ }
1129
+ EntityFieldInfo.applyAccessToBuckets(fp.ReadAccess, allow, deny, 'CanRead');
1130
+ EntityFieldInfo.applyAccessToBuckets(fp.UpdateAccess, allow, deny, 'CanUpdate');
1131
+ EntityFieldInfo.applyAccessToBuckets(fp.CreateAccess, allow, deny, 'CanCreate');
934
1132
  }
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
1133
  }
939
1134
  const effective = {
940
1135
  CanRead: allow.CanRead && !deny.CanRead,
@@ -971,17 +1166,23 @@ export class EntityFieldInfo extends BaseInfo {
971
1166
  * Returns the ValueListType using the EntityFieldValueListType enum.
972
1167
  */
973
1168
  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
- }
1169
+ if (this._valueListTypeEnumCache !== undefined) {
1170
+ return this._valueListTypeEnumCache;
1171
+ }
1172
+ if (this.ValueListType == null) {
1173
+ this._valueListTypeEnumCache = EntityFieldValueListType.None;
1174
+ return this._valueListTypeEnumCache;
1175
+ }
1176
+ const target = this.ValueListType.toLowerCase().trim();
1177
+ for (const enumMember in EntityFieldValueListType) {
1178
+ if (typeof EntityFieldValueListType[enumMember] === 'string' &&
1179
+ enumMember.toLowerCase().trim() === target) {
1180
+ this._valueListTypeEnumCache = EntityFieldValueListType[enumMember];
1181
+ return this._valueListTypeEnumCache;
983
1182
  }
984
1183
  }
1184
+ this._valueListTypeEnumCache = EntityFieldValueListType.None;
1185
+ return this._valueListTypeEnumCache;
985
1186
  }
986
1187
  /**
987
1188
  * Upper bound on how many legal values a validation message enumerates before it truncates.
@@ -1458,19 +1659,28 @@ export class EntityFieldInfo extends BaseInfo {
1458
1659
  * 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
1660
  */
1460
1661
  get IsCreatedAtField() {
1461
- return this.Name.trim().toLowerCase() === EntityInfo.CreatedAtFieldName.trim().toLowerCase();
1662
+ if (this._isCreatedAtFieldCache !== undefined)
1663
+ return this._isCreatedAtFieldCache;
1664
+ this._isCreatedAtFieldCache = this.Name.trim().toLowerCase() === EntityInfo.CreatedAtFieldName.trim().toLowerCase();
1665
+ return this._isCreatedAtFieldCache;
1462
1666
  }
1463
1667
  /**
1464
1668
  * 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
1669
  */
1466
1670
  get IsUpdatedAtField() {
1467
- return this.Name.trim().toLowerCase() === EntityInfo.UpdatedAtFieldName.trim().toLowerCase();
1671
+ if (this._isUpdatedAtFieldCache !== undefined)
1672
+ return this._isUpdatedAtFieldCache;
1673
+ this._isUpdatedAtFieldCache = this.Name.trim().toLowerCase() === EntityInfo.UpdatedAtFieldName.trim().toLowerCase();
1674
+ return this._isUpdatedAtFieldCache;
1468
1675
  }
1469
1676
  /**
1470
1677
  * 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
1678
  */
1472
1679
  get IsDeletedAtField() {
1473
- return this.Name.trim().toLowerCase() === EntityInfo.DeletedAtFieldName.trim().toLowerCase();
1680
+ if (this._isDeletedAtFieldCache !== undefined)
1681
+ return this._isDeletedAtFieldCache;
1682
+ this._isDeletedAtFieldCache = this.Name.trim().toLowerCase() === EntityInfo.DeletedAtFieldName.trim().toLowerCase();
1683
+ return this._isDeletedAtFieldCache;
1474
1684
  }
1475
1685
  /**
1476
1686
  * Returns true if the field is a GUID/UUID column in the database.
@@ -1479,8 +1689,11 @@ export class EntityFieldInfo extends BaseInfo {
1479
1689
  * `uuid`, so a `uniqueidentifier`-only check would miss every UUID column.
1480
1690
  */
1481
1691
  get IsUniqueIdentifier() {
1692
+ if (this._isUniqueIdentifierCache !== undefined)
1693
+ return this._isUniqueIdentifierCache;
1482
1694
  const t = this.Type.trim().toLowerCase();
1483
- return t === 'uniqueidentifier' || t === 'uuid';
1695
+ this._isUniqueIdentifierCache = t === 'uniqueidentifier' || t === 'uuid';
1696
+ return this._isUniqueIdentifierCache;
1484
1697
  }
1485
1698
  /**
1486
1699
  * Returns true if the field's SQL type is **fixed-width / space-padded**
@@ -1493,7 +1706,10 @@ export class EntityFieldInfo extends BaseInfo {
1493
1706
  * (un-padded) form of the value.
1494
1707
  */
1495
1708
  get FixedWidthColumn() {
1496
- return IsFixedWidthStringSQLType(this.Type);
1709
+ if (this._fixedWidthColumnCache !== undefined)
1710
+ return this._fixedWidthColumnCache;
1711
+ this._fixedWidthColumnCache = IsFixedWidthStringSQLType(this.Type);
1712
+ return this._fixedWidthColumnCache;
1497
1713
  }
1498
1714
  /**
1499
1715
  * Returns true if the field has a default value set
@@ -1623,6 +1839,10 @@ export class EntityFieldInfo extends BaseInfo {
1623
1839
  FormatValue(value, decimals = 2, currency = 'USD', maxLength = 0, trailingChars = "...") {
1624
1840
  return FormatValue(this.Type, value, decimals, currency, maxLength, trailingChars);
1625
1841
  }
1842
+ static { this.CHILD_INIT_KEYS = new Set([
1843
+ 'EntityFieldValues', '_EntityFieldValues',
1844
+ 'EntityFieldPermissions', '_FieldPermissions', 'FieldPermissions',
1845
+ ]); }
1626
1846
  constructor(initData = null) {
1627
1847
  super();
1628
1848
  this.ID = null;
@@ -2007,6 +2227,7 @@ export class EntityFieldInfo extends BaseInfo {
2007
2227
  */
2008
2228
  this._relatedEntityJoinFieldsFailedParsing = false;
2009
2229
  this._fieldPermissions = [];
2230
+ this._valueListTypeEnumCache = undefined;
2010
2231
  /** Memoized {@link TSType} — `Type` is immutable after metadata load, so the classification never changes. */
2011
2232
  this._tsType = undefined;
2012
2233
  /**
@@ -2014,8 +2235,14 @@ export class EntityFieldInfo extends BaseInfo {
2014
2235
  * This property returns a consistent CodeName you can use everywhere to refer to the field when generated variable names
2015
2236
  */
2016
2237
  this._codeName = null;
2238
+ this._isCreatedAtFieldCache = undefined;
2239
+ this._isUpdatedAtFieldCache = undefined;
2240
+ this._isDeletedAtFieldCache = undefined;
2241
+ this._isUniqueIdentifierCache = undefined;
2242
+ this._fixedWidthColumnCache = undefined;
2243
+ this._parsedDefaultValueCache = undefined;
2017
2244
  if (initData) {
2018
- this.copyInitData(initData);
2245
+ this.copyInitData(initData, EntityFieldInfo.CHILD_INIT_KEYS);
2019
2246
  // Lazy-construct EntityFieldValueInfo objects — stash raw data now,
2020
2247
  // construct typed instances on first access via the getter.
2021
2248
  // This eliminates ~36,000 object constructions during startup deserialization.
@@ -2096,7 +2323,157 @@ export class EntityFieldInfo extends BaseInfo {
2096
2323
  // Using includes() because the value might be wrapped in parentheses like (getdate())
2097
2324
  return EntityFieldInfo.SQL_CURRENT_DATE_FUNCTIONS.some(func => normalizedValue.includes(func));
2098
2325
  }
2326
+ static { this.PG_TYPED_LITERAL_REGEX = /^'((?:[^']|'')*)'::[A-Za-z][\w "()\[\],]*$/; }
2327
+ /**
2328
+ * Parses a field's SQL DefaultValue into its runtime JS representation.
2329
+ * Exposed as a static helper so unit tests passing plain-object mocks for EntityFieldInfo
2330
+ * work identically to real EntityFieldInfo instances.
2331
+ */
2332
+ static ParseDefaultValue(field) {
2333
+ const rawDefault = field.DefaultValue;
2334
+ if (!rawDefault) {
2335
+ return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
2336
+ }
2337
+ if (field.TSType === EntityFieldTSType.Boolean) {
2338
+ const val = typeof rawDefault === 'string' &&
2339
+ (rawDefault.trim() === '1' || rawDefault.trim().toLowerCase() === 'true');
2340
+ return { source: rawDefault, hasValue: true, value: val, isCurrentDateFunction: false };
2341
+ }
2342
+ if (field.TSType === EntityFieldTSType.Number) {
2343
+ const num = Number(rawDefault);
2344
+ if (!isNaN(num)) {
2345
+ return { source: rawDefault, hasValue: true, value: num, isCurrentDateFunction: false };
2346
+ }
2347
+ if (typeof rawDefault === 'string' && rawDefault.trim().toLowerCase() === 'null') {
2348
+ return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
2349
+ }
2350
+ return { source: rawDefault, hasValue: false, value: undefined, isCurrentDateFunction: false };
2351
+ }
2352
+ const isUniqueId = field.IsUniqueIdentifier !== undefined
2353
+ ? field.IsUniqueIdentifier
2354
+ : (typeof field.Type === 'string' && (field.Type.trim().toLowerCase() === 'uniqueidentifier' || field.Type.trim().toLowerCase() === 'uuid'));
2355
+ if (isUniqueId) {
2356
+ return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
2357
+ }
2358
+ if (field.TSType === EntityFieldTSType.Date) {
2359
+ if (typeof rawDefault === 'string' && rawDefault.trim().length > 0) {
2360
+ const temp = new Date(rawDefault);
2361
+ if (!isNaN(temp.getTime())) {
2362
+ return { source: rawDefault, hasValue: true, value: temp, isCurrentDateFunction: false };
2363
+ }
2364
+ if (EntityFieldInfo.IsDefaultValueSQLCurrentDateFunction(rawDefault)) {
2365
+ if (field.IsSpecialDateField) {
2366
+ return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
2367
+ }
2368
+ return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: true };
2369
+ }
2370
+ 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`);
2371
+ }
2372
+ return { source: rawDefault, hasValue: false, value: undefined, isCurrentDateFunction: false };
2373
+ }
2374
+ const dv = typeof rawDefault === 'string' ? rawDefault.trim() : String(rawDefault);
2375
+ const m = dv.match(EntityFieldInfo.PG_TYPED_LITERAL_REGEX);
2376
+ const strVal = m ? m[1].replace(/''/g, "'") : rawDefault;
2377
+ return { source: rawDefault, hasValue: true, value: strVal, isCurrentDateFunction: false };
2378
+ }
2379
+ /**
2380
+ * Parses and memoizes the SQL DefaultValue into its runtime JS representation once per
2381
+ * EntityFieldInfo instead of re-parsing (trimming, regex matching, Number/Date parsing)
2382
+ * on every EntityField instantiation.
2383
+ */
2384
+ GetParsedDefaultValue() {
2385
+ const rawDefault = this.DefaultValue;
2386
+ if (this._parsedDefaultValueCache !== undefined && this._parsedDefaultValueCache.source === rawDefault) {
2387
+ return this._parsedDefaultValueCache;
2388
+ }
2389
+ this._parsedDefaultValueCache = EntityFieldInfo.ParseDefaultValue(this);
2390
+ return this._parsedDefaultValueCache;
2391
+ }
2392
+ }
2393
+ const _emptyStringSet = new Set();
2394
+ const _userRoleIdSetCache = new WeakMap();
2395
+ function getUserRoleIdSet(user) {
2396
+ if (!user?.UserRoles || user.UserRoles.length === 0) {
2397
+ return _emptyStringSet;
2398
+ }
2399
+ const roles = user.UserRoles;
2400
+ const len = roles.length;
2401
+ const firstId = roles[0]?.RoleID;
2402
+ const cached = _userRoleIdSetCache.get(user);
2403
+ if (cached && cached.rolesRef === roles && cached.rolesLen === len && cached.firstRoleId === firstId) {
2404
+ return cached.roleIds;
2405
+ }
2406
+ const roleIds = new Set();
2407
+ for (let i = 0; i < len; i++) {
2408
+ const rid = roles[i]?.RoleID;
2409
+ if (rid) {
2410
+ roleIds.add(rid.trim().toLowerCase());
2411
+ }
2412
+ }
2413
+ _userRoleIdSetCache.set(user, { rolesRef: roles, rolesLen: len, firstRoleId: firstId, roleIds });
2414
+ return roleIds;
2415
+ }
2416
+ const _entityFieldIndexCache = new WeakMap();
2417
+ const _entityDeniedFieldsCache = new WeakMap();
2418
+ /**
2419
+ * The field-security generation: one process-wide number that moves whenever an answer derived
2420
+ * from field-security configuration may have changed. It moves when an `EntityInfo` is built (a
2421
+ * metadata refresh rebuilds every entity), when an entity's {@link EntityInfo.EnableFieldLevelSecurity}
2422
+ * flag is assigned, and when {@link EntityInfo.InvalidateFieldSecurityCache} is called. Every memo
2423
+ * of a field-security answer records the number it was filled under and is discarded once the
2424
+ * number has moved, so a memo is never served across any of those changes. Numbers only grow.
2425
+ */
2426
+ let _fieldSecurityGeneration = 0;
2427
+ /** Marks every memoized field-security answer stale (see {@link CurrentFieldSecurityGeneration}). */
2428
+ function advanceFieldSecurityGeneration() {
2429
+ _fieldSecurityGeneration++;
2099
2430
  }
2431
+ /**
2432
+ * The current field-security generation. A memo of anything derived from field-security
2433
+ * configuration (denied sets, whether field security applies, a user's denial profile across a
2434
+ * catalog) is valid only while this returns the number the memo was filled under. It moves when an
2435
+ * `EntityInfo` is built, when an entity's `EnableFieldLevelSecurity` flag is assigned, and when
2436
+ * `EntityInfo.InvalidateFieldSecurityCache()` is called.
2437
+ */
2438
+ export function CurrentFieldSecurityGeneration() {
2439
+ return _fieldSecurityGeneration;
2440
+ }
2441
+ const NO_ANCESTORS = Object.freeze([]);
2442
+ /** Whether two ancestor lists hold the same entities, by identity, in the same order. */
2443
+ function sameAncestors(a, b) {
2444
+ if (a === b) {
2445
+ return true;
2446
+ }
2447
+ if (a.length !== b.length) {
2448
+ return false;
2449
+ }
2450
+ for (let i = 0; i < a.length; i++) {
2451
+ if (a[i] !== b[i]) {
2452
+ return false;
2453
+ }
2454
+ }
2455
+ return true;
2456
+ }
2457
+ /**
2458
+ * The role IDs a user holds, in order, as one string: changes whenever a role is added, removed
2459
+ * or swapped. Field-level security reads nothing else about a user, so this, with
2460
+ * {@link CurrentFieldSecurityGeneration}, keys any memo of a user's field-security answers.
2461
+ */
2462
+ export function FieldSecurityUserRolesKey(user) {
2463
+ return userRolesKey(user);
2464
+ }
2465
+ function userRolesKey(user) {
2466
+ const roles = user.UserRoles;
2467
+ if (!roles || roles.length === 0) {
2468
+ return '';
2469
+ }
2470
+ let key = '';
2471
+ for (let i = 0; i < roles.length; i++) {
2472
+ key += `${roles[i]?.RoleID ?? ''}|`;
2473
+ }
2474
+ return key;
2475
+ }
2476
+ const _entityChildEntitiesCache = new WeakMap();
2100
2477
  /**
2101
2478
  * 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
2479
  */
@@ -2222,6 +2599,28 @@ export class EntityInfo extends BaseInfo {
2222
2599
  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.`);
2223
2600
  return false;
2224
2601
  }
2602
+ /**
2603
+ * Whether field-level (column-level) security is enforced for this entity.
2604
+ *
2605
+ * This is the single gate every field-security enforcement point checks first, and it is
2606
+ * explicit — never inferred from whether permission rows happen to exist. It is `false` for
2607
+ * nearly every entity in nearly every deployment, so enforcement collapses to one boolean
2608
+ * test: no field iteration, no aggregation, no allocation.
2609
+ *
2610
+ * Turning it on snapshots the entity's existing entity-level permissions into per-field
2611
+ * rows, so enabling changes no behavior until an administrator tightens a field. Turning it
2612
+ * off leaves the rows in place, inactive, so re-enabling does not lose the configuration.
2613
+ *
2614
+ * Assigning it moves the field-security generation ({@link CurrentFieldSecurityGeneration}),
2615
+ * so every memoized field-security answer that read the old value is recomputed.
2616
+ */
2617
+ get EnableFieldLevelSecurity() {
2618
+ return this._enableFieldLevelSecurity;
2619
+ }
2620
+ set EnableFieldLevelSecurity(value) {
2621
+ this._enableFieldLevelSecurity = value;
2622
+ advanceFieldSecurityGeneration();
2623
+ }
2225
2624
  /**
2226
2625
  * True when at least one field is a writable Geo* source (street and/or native lat/lng).
2227
2626
  * Person/Org PrimaryAddress* are virtual display fields and do **not** count.
@@ -2327,6 +2726,30 @@ export class EntityInfo extends BaseInfo {
2327
2726
  get CloneConfig() {
2328
2727
  return this.ConfigurationObject?.Clone ?? null;
2329
2728
  }
2729
+ /**
2730
+ * Controls which columns a save operation (`spCreate` / `spUpdate`) returns from the database
2731
+ * for this entity. Defaults to `'full'` when `Configuration.SaveReturnMode` is omitted or not
2732
+ * `'datestamps_only'`.
2733
+ */
2734
+ get SaveReturnMode() {
2735
+ return this.ConfigurationObject?.SaveReturnMode === 'datestamps_only' ? 'datestamps_only' : 'full';
2736
+ }
2737
+ /**
2738
+ * Returns the ordered list of base-table fields returned by a `'datestamps_only'` save:
2739
+ * the entity's primary key field(s), followed by `__mj_CreatedAt` and `__mj_UpdatedAt`
2740
+ * when those columns exist on the entity's base table.
2741
+ */
2742
+ get DatestampReturnFields() {
2743
+ const result = [...this.PrimaryKeys];
2744
+ const pkNames = new Set(result.map(k => k.Name.trim().toLowerCase()));
2745
+ const createdAt = this.Fields.find(f => !f.IsVirtual && f.IsCreatedAtField && !pkNames.has(f.Name.trim().toLowerCase()));
2746
+ if (createdAt)
2747
+ result.push(createdAt);
2748
+ const updatedAt = this.Fields.find(f => !f.IsVirtual && f.IsUpdatedAtField && !pkNames.has(f.Name.trim().toLowerCase()));
2749
+ if (updatedAt)
2750
+ result.push(updatedAt);
2751
+ return result;
2752
+ }
2330
2753
  /**
2331
2754
  * Returns true if cloning is explicitly enabled for this entity as a ROOT record.
2332
2755
  */
@@ -2379,6 +2802,87 @@ export class EntityInfo extends BaseInfo {
2379
2802
  set _floatCount(value) {
2380
2803
  this.FloatCount = value;
2381
2804
  }
2805
+ /**
2806
+ * Whether field-level security decides anything on this entity: its own
2807
+ * {@link EnableFieldLevelSecurity} flag is on, or an IS-A ancestor's is.
2808
+ *
2809
+ * **Enforcement points gate on this, never on the raw flag.** An IS-A child carries each
2810
+ * ancestor's fields as mirror fields of its own, read from the same row. Gating on the child's
2811
+ * own flag — off on nearly every child, because nobody turns it on there — let a role denied
2812
+ * `Phone` on the parent read it through the child's RunView, load and `GetAll()`. The denied
2813
+ * sets ({@link GetDeniedReadFields} and siblings) carry the ancestor's denials for those mirror
2814
+ * fields; this is the matching gate, so a caller that skips work when it is false never skips
2815
+ * an ancestor's rules.
2816
+ *
2817
+ * The raw flag stays the right question for CONFIGURATION — which entities reconciliation,
2818
+ * CodeGen and the system-user sweeps cover — because the rules themselves live on the entity
2819
+ * that owns the field.
2820
+ */
2821
+ get FieldLevelSecurityApplies() {
2822
+ if (this.EnableFieldLevelSecurity) {
2823
+ return true; // checked first: one boolean for every entity that has it on
2824
+ }
2825
+ if (!this.ParentID) {
2826
+ return false; // one boolean for every entity outside an IS-A chain
2827
+ }
2828
+ // The ancestors are memoized; their flags are read live, so an in-place flip is seen at once.
2829
+ const ancestors = this.liveAncestors();
2830
+ for (let i = 0; i < ancestors.length; i++) {
2831
+ if (ancestors[i].EnableFieldLevelSecurity) {
2832
+ return true;
2833
+ }
2834
+ }
2835
+ return false;
2836
+ }
2837
+ /**
2838
+ * This entity's IS-A ancestors, nearest first, as {@link ParentEntityInfo} resolves them NOW
2839
+ * rather than the cached {@link ParentChain}. The denied walk ({@link addFieldsDeniedOnParent})
2840
+ * follows the live parent, so the gate and the memo key must too: read from a chain cached
2841
+ * before a metadata refresh replaced an ancestor, the gate could answer "no" while the
2842
+ * ancestor now in place has rules, and every enforcement point would skip them.
2843
+ *
2844
+ * The walk is memoized, valid while the field-security generation (which moves whenever any
2845
+ * `EntityInfo` is built, so a rebuilt ancestor is noticed), the provider this entity resolves
2846
+ * its parent through (its owning provider, else the global one), that provider's entity array
2847
+ * and this entity's `ParentID` are unchanged. Ancestors resolve their own parents through
2848
+ * their own providers; a chain whose entities came from one provider, the only way providers
2849
+ * build them, is covered by that provider's array. A walk cut
2850
+ * short because a `ParentID` did not resolve (asked before the provider held its entities) is
2851
+ * returned but not kept, so it cannot pin an incomplete answer.
2852
+ */
2853
+ liveAncestors() {
2854
+ if (!this.ParentID) {
2855
+ return NO_ANCESTORS;
2856
+ }
2857
+ const provider = MetadataProviderFor(this); // the provider ParentEntityInfo resolves through
2858
+ const entities = provider?.Entities;
2859
+ const memo = this._liveAncestorsMemo;
2860
+ if (memo !== null && memo.generation === _fieldSecurityGeneration && memo.provider === provider
2861
+ && memo.entities === entities && memo.parentID === this.ParentID) {
2862
+ return memo.ancestors;
2863
+ }
2864
+ const generation = _fieldSecurityGeneration;
2865
+ const { ancestors, complete } = this.walkLiveAncestors();
2866
+ if (complete) {
2867
+ this._liveAncestorsMemo = { generation, provider, entities, parentID: this.ParentID, ancestors };
2868
+ }
2869
+ return ancestors;
2870
+ }
2871
+ /** The uncached walk behind {@link liveAncestors}; stops at the root or at a cycle. */
2872
+ walkLiveAncestors() {
2873
+ const ancestors = [];
2874
+ const visited = new Set([this]);
2875
+ let current = this;
2876
+ let ancestor = this.ParentEntityInfo;
2877
+ while (ancestor && !visited.has(ancestor)) {
2878
+ visited.add(ancestor);
2879
+ ancestors.push(ancestor);
2880
+ current = ancestor;
2881
+ ancestor = ancestor.ParentID ? ancestor.ParentEntityInfo : null;
2882
+ }
2883
+ const complete = !current.ParentID || ancestor !== null;
2884
+ return { ancestors: Object.freeze(ancestors), complete };
2885
+ }
2382
2886
  /**
2383
2887
  * The set of field names this user may NOT READ on this entity — the per-request primitive
2384
2888
  * every field-security enforcement point is built on.
@@ -2389,12 +2893,13 @@ export class EntityInfo extends BaseInfo {
2389
2893
  * re-scans `user.UserRoles` and allocates. A Set lookup costs neither.
2390
2894
  *
2391
2895
  * Names are lowercased so callers can match case-insensitively, consistent with
2392
- * {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when the entity has
2393
- * field security switched off and when the user is denied nothing, so callers can treat
2896
+ * {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when field security
2897
+ * does not apply (see {@link FieldLevelSecurityApplies}) and when the user is denied nothing,
2898
+ * so callers can treat
2394
2899
  * `size === 0` as the single "nothing to do" condition.
2395
2900
  */
2396
2901
  GetDeniedReadFields(user) {
2397
- return this.getDeniedFields(user, (p) => !p.CanRead);
2902
+ return this.getCachedDeniedFields(user, 'read', (p) => !p.CanRead);
2398
2903
  }
2399
2904
  /**
2400
2905
  * Whether this user may READ the named field — the single-field question, answered the same
@@ -2410,10 +2915,10 @@ export class EntityInfo extends BaseInfo {
2410
2915
  * The caller still decides what to render in place of the value, which is the part that
2411
2916
  * differs per surface and should not be hidden inside a getter.
2412
2917
  *
2413
- * **Fails open** on a missing user, a missing field name, or an entity with field security
2414
- * switched off — matching `BaseEntity`'s own gate and `MjFormFieldComponent`. The server is
2415
- * the real boundary; a UI that blanked out fields because no user had resolved yet would be
2416
- * worse than one that shows them.
2918
+ * **Fails open** on a missing user, a missing field name, or an entity where field security
2919
+ * does not apply ({@link FieldLevelSecurityApplies}) — matching `BaseEntity`'s own gate and
2920
+ * `MjFormFieldComponent`. The server is the real boundary; a UI that blanked out fields
2921
+ * because no user had resolved yet would be worse than one that shows them.
2417
2922
  *
2418
2923
  * PERFORMANCE: this delegates to {@link GetDeniedReadFields}, which walks every field on the
2419
2924
  * entity. Fine for the handful of chrome reads it exists for; do NOT call it per row in a
@@ -2423,7 +2928,7 @@ export class EntityInfo extends BaseInfo {
2423
2928
  * @param user the acting user
2424
2929
  */
2425
2930
  IsFieldReadableByUser(fieldName, user) {
2426
- if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2931
+ if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
2427
2932
  return true;
2428
2933
  }
2429
2934
  return !this.GetDeniedReadFields(user).has(fieldName.trim().toLowerCase());
@@ -2435,7 +2940,7 @@ export class EntityInfo extends BaseInfo {
2435
2940
  * three conditions.
2436
2941
  */
2437
2942
  IsFieldUpdatableByUser(fieldName, user) {
2438
- if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2943
+ if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
2439
2944
  return true;
2440
2945
  }
2441
2946
  return !this.GetDeniedUpdateFields(user).has(fieldName.trim().toLowerCase());
@@ -2449,7 +2954,7 @@ export class EntityInfo extends BaseInfo {
2449
2954
  * the user typed, which is the case this exists to prevent.
2450
2955
  */
2451
2956
  IsFieldCreatableByUser(fieldName, user) {
2452
- if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2957
+ if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
2453
2958
  return true;
2454
2959
  }
2455
2960
  return !this.GetDeniedCreateFields(user).has(fieldName.trim().toLowerCase());
@@ -2463,7 +2968,7 @@ export class EntityInfo extends BaseInfo {
2463
2968
  * actually need rather than relying on that.
2464
2969
  */
2465
2970
  GetDeniedUpdateFields(user) {
2466
- return this.getDeniedFields(user, (p) => !p.CanUpdate);
2971
+ return this.getCachedDeniedFields(user, 'update', (p) => !p.CanUpdate);
2467
2972
  }
2468
2973
  /**
2469
2974
  * The set of field names this user may NOT supply a value for when CREATING a record. Same
@@ -2473,11 +2978,69 @@ export class EntityInfo extends BaseInfo {
2473
2978
  * create-denied field is dropped and the column takes its default.
2474
2979
  */
2475
2980
  GetDeniedCreateFields(user) {
2476
- return this.getDeniedFields(user, (p) => !p.CanCreate);
2981
+ return this.getCachedDeniedFields(user, 'create', (p) => !p.CanCreate);
2982
+ }
2983
+ /**
2984
+ * Discards the memoized field-security answers: this entity's denied-field sets, those of
2985
+ * every IS-A descendant, and every other memo keyed on the field-security generation
2986
+ * ({@link CurrentFieldSecurityGeneration}), such as a user's denial profile across a catalog.
2987
+ * Invalidation is process-wide because a rule on one entity can change answers about others.
2988
+ *
2989
+ * A metadata refresh needs no call: it builds new `EntityInfo` objects, which start with
2990
+ * empty memos. Call this only after changing field rules IN PLACE on an existing
2991
+ * `EntityInfo`, for example editing an {@link EntityFieldPermissionInfo}'s access values.
2992
+ */
2993
+ InvalidateFieldSecurityCache() {
2994
+ advanceFieldSecurityGeneration();
2995
+ }
2996
+ /**
2997
+ * Memoized front for {@link getDeniedFields}, per (entity, user, verb).
2998
+ *
2999
+ * An entry is reused while four things are unchanged: the field-security generation (see
3000
+ * {@link CurrentFieldSecurityGeneration}; it moves on every rebuild, flag assignment and
3001
+ * {@link InvalidateFieldSecurityCache}), the IS-A ancestors as they resolve now, the field
3002
+ * array itself, and the role IDs the user holds. Counting fields or rules is not enough:
3003
+ * editing a rule from `Allow` to `Deny` changes neither count.
3004
+ */
3005
+ getCachedDeniedFields(user, verb, isDenied) {
3006
+ if (!this.FieldLevelSecurityApplies) {
3007
+ return new Set(); // gate on inheritance too: an ancestor's rules still apply
3008
+ }
3009
+ if (!user || typeof user !== 'object') {
3010
+ return this.getDeniedFields(user, verb, isDenied);
3011
+ }
3012
+ const entry = this.deniedFieldsMemoEntryFor(user);
3013
+ const existing = entry[verb];
3014
+ if (existing !== undefined) {
3015
+ return existing;
3016
+ }
3017
+ const computed = this.getDeniedFields(user, verb, isDenied);
3018
+ entry[verb] = computed;
3019
+ return computed;
3020
+ }
3021
+ /** The current memo entry for `user`, replaced with an empty one when any part of its key moved. */
3022
+ deniedFieldsMemoEntryFor(user) {
3023
+ const generation = _fieldSecurityGeneration;
3024
+ const ancestors = this.liveAncestors();
3025
+ const rolesKey = userRolesKey(user);
3026
+ let byUser = _entityDeniedFieldsCache.get(this);
3027
+ if (!byUser) {
3028
+ byUser = new WeakMap();
3029
+ _entityDeniedFieldsCache.set(this, byUser);
3030
+ }
3031
+ let entry = byUser.get(user);
3032
+ if (!entry || entry.fieldsRef !== this._fields || entry.generation !== generation
3033
+ || !sameAncestors(entry.ancestors, ancestors) || entry.rolesKey !== rolesKey) {
3034
+ entry = { fieldsRef: this._fields, generation, ancestors, rolesKey };
3035
+ byUser.set(user, entry);
3036
+ }
3037
+ return entry;
2477
3038
  }
2478
3039
  /**
2479
3040
  * Shared walk behind {@link GetDeniedReadFields} / {@link GetDeniedUpdateFields} /
2480
- * {@link GetDeniedCreateFields}, short-circuiting on {@link EnableFieldLevelSecurity}.
3041
+ * {@link GetDeniedCreateFields}. Walks this entity's own fields when its
3042
+ * {@link EnableFieldLevelSecurity} is on, then adds the fields an IS-A parent denies (see
3043
+ * {@link addFieldsDeniedOnParent}).
2481
3044
  *
2482
3045
  * Every field is aggregated, including those carrying no permission records — on an enabled
2483
3046
  * entity those are denied. Unrestrictable fields (primary keys, `__mj_` columns) come back
@@ -2500,18 +3063,54 @@ export class EntityInfo extends BaseInfo {
2500
3063
  * that collide on `CodeName` already collide on their generated property, which is not a
2501
3064
  * schema CodeGen can emit.
2502
3065
  */
2503
- getDeniedFields(user, isDenied) {
3066
+ getDeniedFields(user, verb, isDenied) {
2504
3067
  const denied = new Set();
2505
- if (!this.EnableFieldLevelSecurity) {
2506
- return denied;
3068
+ if (this.EnableFieldLevelSecurity) {
3069
+ for (const field of this._fields) {
3070
+ if (isDenied(field.GetUserFieldPermissions(user, true))) {
3071
+ this.addDeniedField(denied, field);
3072
+ }
3073
+ }
3074
+ }
3075
+ this.addFieldsDeniedOnParent(denied, user, verb, isDenied);
3076
+ return denied;
3077
+ }
3078
+ /**
3079
+ * IS-A inheritance for the denied sets: a field the immediate parent denies is denied on this
3080
+ * entity's mirror of it, whatever this entity's own flag says. The parent's set already
3081
+ * includes ITS parent's denials (this method runs there too), so one hop covers any depth.
3082
+ *
3083
+ * A mirror is a field of this entity whose `Name` is the `Name` of one of the parent's own
3084
+ * fields: an IS-A child's view exposes each parent column under the parent's column name, and
3085
+ * a child cannot declare a column of its own with that name. Matching goes through the
3086
+ * parent's field list rather than straight into the parent's denied set, because that set also
3087
+ * holds each denied field's `CodeName`: a parent field `Base Salary` puts `base_salary` in it,
3088
+ * and a child's OWN field named `Base_Salary` must not be denied for that.
3089
+ *
3090
+ * Additive only. When this entity ALSO has field security on, a mirror with no rows of its own
3091
+ * is already denied by the walk above (no rows on an enabled entity means denied); the parent's
3092
+ * rules cannot re-open it.
3093
+ */
3094
+ addFieldsDeniedOnParent(denied, user, verb, isDenied) {
3095
+ const parent = this.ParentEntityInfo;
3096
+ if (!parent?.FieldLevelSecurityApplies) {
3097
+ return;
3098
+ }
3099
+ const parentDenied = parent.getCachedDeniedFields(user, verb, isDenied);
3100
+ if (parentDenied.size === 0) {
3101
+ return;
2507
3102
  }
2508
3103
  for (const field of this._fields) {
2509
- if (isDenied(field.GetUserFieldPermissions(user, true))) {
2510
- denied.add(field.Name.trim().toLowerCase());
2511
- denied.add(field.CodeName.trim().toLowerCase());
3104
+ const parentField = parent.FieldByName(field.Name);
3105
+ if (parentField && parentDenied.has(parentField.Name.trim().toLowerCase())) {
3106
+ this.addDeniedField(denied, field);
2512
3107
  }
2513
3108
  }
2514
- return denied;
3109
+ }
3110
+ /** Adds a field under BOTH key spaces — see {@link getDeniedFields} for why. */
3111
+ addDeniedField(denied, field) {
3112
+ denied.add(field.Name.trim().toLowerCase());
3113
+ denied.add(field.CodeName.trim().toLowerCase());
2515
3114
  }
2516
3115
  /**
2517
3116
  * O(1) case-insensitive field lookup by name. Use this instead of `Fields.find(f => f.Name === name)`
@@ -2528,8 +3127,9 @@ export class EntityInfo extends BaseInfo {
2528
3127
  if (name == null)
2529
3128
  return undefined;
2530
3129
  if (this._fieldByNameMap === null) {
3130
+ const fields = this._fields ?? this.Fields ?? [];
2531
3131
  const map = new Map();
2532
- for (const f of this._fields) {
3132
+ for (const f of fields) {
2533
3133
  if (f.Name != null)
2534
3134
  map.set(f.Name.trim().toLowerCase(), f);
2535
3135
  }
@@ -2537,6 +3137,62 @@ export class EntityInfo extends BaseInfo {
2537
3137
  }
2538
3138
  return this._fieldByNameMap.get(name.trim().toLowerCase());
2539
3139
  }
3140
+ ensureFieldIndexEntry() {
3141
+ const fields = this._fields ?? this.Fields ?? [];
3142
+ let entry = _entityFieldIndexCache.get(this);
3143
+ if (!entry || entry.fieldsRef !== fields || entry.fieldsLen !== fields.length) {
3144
+ const byName = new Map();
3145
+ const byCodeName = new Map();
3146
+ for (let i = 0; i < fields.length; i++) {
3147
+ const f = fields[i];
3148
+ if (f.Name != null) {
3149
+ const nameKey = f.Name.trim().toLowerCase();
3150
+ if (!byName.has(nameKey)) {
3151
+ byName.set(nameKey, i);
3152
+ }
3153
+ const rawCodeName = f.CodeName ?? f.Name;
3154
+ const codeKey = rawCodeName.trim().toLowerCase();
3155
+ if (!byCodeName.has(codeKey)) {
3156
+ byCodeName.set(codeKey, i);
3157
+ }
3158
+ }
3159
+ }
3160
+ entry = { fieldsRef: fields, fieldsLen: fields.length, byName, byCodeName };
3161
+ _entityFieldIndexCache.set(this, entry);
3162
+ }
3163
+ return entry;
3164
+ }
3165
+ /**
3166
+ * O(1) case-insensitive lookup of a field's positional index in `Fields` by `Name`.
3167
+ * Shared across all `BaseEntity` instances of this `EntityInfo` so individual entity
3168
+ * instances do not need to allocate their own `Map<string, EntityField>` on hydration.
3169
+ */
3170
+ FieldIndexByName(name) {
3171
+ if (!name)
3172
+ return -1;
3173
+ return this.ensureFieldIndexEntry().byName.get(name.trim().toLowerCase()) ?? -1;
3174
+ }
3175
+ /**
3176
+ * O(1) case-insensitive lookup of a field's positional index in `Fields` by `CodeName`.
3177
+ */
3178
+ FieldIndexByCodeName(codeName) {
3179
+ if (!codeName)
3180
+ return -1;
3181
+ return this.ensureFieldIndexEntry().byCodeName.get(codeName.trim().toLowerCase()) ?? -1;
3182
+ }
3183
+ /**
3184
+ * O(1) case-insensitive field lookup by `CodeName`. Uses the shared `_entityFieldIndexCache`
3185
+ * index so GraphQL resolvers and mutation handlers can resolve client `CodeName` keys without
3186
+ * linear `Fields.find(...)` scans.
3187
+ *
3188
+ * @param codeName field CodeName (matched case-insensitively, whitespace-trimmed)
3189
+ * @returns the matching EntityFieldInfo, or undefined if not found
3190
+ */
3191
+ FieldByCodeName(codeName) {
3192
+ const idx = this.FieldIndexByCodeName(codeName);
3193
+ const fields = this._fields ?? this.Fields;
3194
+ return idx >= 0 && fields ? fields[idx] : undefined;
3195
+ }
2540
3196
  /**
2541
3197
  * Returns the primary key field for the entity. For entities with a composite primary key, use the PrimaryKeys property which returns all.
2542
3198
  * In the case of a composite primary key, the PrimaryKey property will return the first field in the sequence of the primary key fields.
@@ -2715,6 +3371,53 @@ export class EntityInfo extends BaseInfo {
2715
3371
  get OrganicKeys() {
2716
3372
  return this._organicKeys;
2717
3373
  }
3374
+ /**
3375
+ * Gets all registered alternate base views for this entity, sorted by `Sequence`, `Name`, and `ID`.
3376
+ * Alternate base views maintain 100% column parity with {@link BaseView} while applying a
3377
+ * curated canonical filter or join.
3378
+ * @returns {EntityAlternateBaseViewInfo[]} Array of alternate base view metadata definitions
3379
+ */
3380
+ get AlternateBaseViews() {
3381
+ return this._alternateBaseViews;
3382
+ }
3383
+ /**
3384
+ * Finds a registered alternate base view on this entity by its semantic `Name`
3385
+ * (case-insensitive, whitespace-trimmed).
3386
+ *
3387
+ * @param name Semantic name of the alternate base view (for example, `'Active'`).
3388
+ * @returns The matching {@link EntityAlternateBaseViewInfo}, or `undefined` if not found.
3389
+ */
3390
+ GetAlternateBaseView(name) {
3391
+ const trimmed = name?.trim().toLowerCase();
3392
+ if (!trimmed) {
3393
+ return undefined;
3394
+ }
3395
+ return this._alternateBaseViews.find(v => v.Name?.trim().toLowerCase() === trimmed);
3396
+ }
3397
+ /**
3398
+ * Finds a registered alternate base view on this entity by its physical SQL `ViewName`
3399
+ * (and optional `schemaName`), matched case-insensitively and whitespace-trimmed.
3400
+ *
3401
+ * @param viewName Physical SQL view name (for example, `'vwActiveMembers'`).
3402
+ * @param schemaName Optional schema name; when supplied, must match `EffectiveSchemaName` case-insensitively.
3403
+ * @returns The matching {@link EntityAlternateBaseViewInfo}, or `undefined` if not found.
3404
+ */
3405
+ GetAlternateBaseViewByViewName(viewName, schemaName) {
3406
+ const trimmedView = viewName?.trim().toLowerCase();
3407
+ if (!trimmedView) {
3408
+ return undefined;
3409
+ }
3410
+ const trimmedSchema = schemaName?.trim().toLowerCase();
3411
+ return this._alternateBaseViews.find(v => {
3412
+ if (v.ViewName?.trim().toLowerCase() !== trimmedView) {
3413
+ return false;
3414
+ }
3415
+ if (trimmedSchema && trimmedSchema.length > 0) {
3416
+ return v.EffectiveSchemaName.trim().toLowerCase() === trimmedSchema;
3417
+ }
3418
+ return true;
3419
+ });
3420
+ }
2718
3421
  static { this.__createdAtFieldName = '__mj_CreatedAt'; }
2719
3422
  static { this.__updatedAtFieldName = '__mj_UpdatedAt'; }
2720
3423
  static { this.__deletedAtFieldName = '__mj_DeletedAt'; }
@@ -2859,7 +3562,7 @@ export class EntityInfo extends BaseInfo {
2859
3562
  get ParentEntityInfo() {
2860
3563
  if (!this.ParentID)
2861
3564
  return null;
2862
- const p = Metadata.Provider; // global-provider-ok: stateless info class — proxies to global metadata
3565
+ const p = MetadataProviderFor(this);
2863
3566
  if (p?.EntityByID) {
2864
3567
  return p.EntityByID(this.ParentID) ?? null;
2865
3568
  }
@@ -2875,7 +3578,23 @@ export class EntityInfo extends BaseInfo {
2875
3578
  * only one child type is allowed per parent record (disjoint subtypes).
2876
3579
  */
2877
3580
  get ChildEntities() {
2878
- return Metadata.Provider?.Entities?.filter(e => UUIDsEqual(e.ParentID, this.ID)) ?? []; // global-provider-ok: stateless info class — proxies to global metadata
3581
+ const provider = MetadataProviderFor(this);
3582
+ const entities = provider?.Entities;
3583
+ if (!entities || entities.length === 0) {
3584
+ return [];
3585
+ }
3586
+ const cached = _entityChildEntitiesCache.get(this);
3587
+ if (cached && cached.provider === provider && cached.entitiesRef === entities && cached.entitiesLen === entities.length) {
3588
+ return cached.children;
3589
+ }
3590
+ const children = entities.filter(e => UUIDsEqual(e.ParentID, this.ID));
3591
+ _entityChildEntitiesCache.set(this, {
3592
+ provider,
3593
+ entitiesRef: entities,
3594
+ entitiesLen: entities.length,
3595
+ children,
3596
+ });
3597
+ return children;
2879
3598
  }
2880
3599
  /**
2881
3600
  * Convenience alias: returns true when this entity is a parent type that allows
@@ -2889,22 +3608,33 @@ export class EntityInfo extends BaseInfo {
2889
3608
  * Walks the IS-A chain upward from this entity to the root, returning all parent entities.
2890
3609
  * Does NOT include this entity itself.
2891
3610
  * Example: For Webinars (IS-A Meetings IS-A Products), returns [Meetings, Products].
2892
- * Results are cached after first computation for performance.
3611
+ * Results are cached after first computation for performance — but only a COMPLETE chain:
3612
+ * one that ends at an entity with no parent, or at a cycle. A walk cut short because a
3613
+ * `ParentID` did not resolve (asked before the provider had its entities) is returned but not
3614
+ * kept, so it cannot pin an incomplete answer; field security reads this chain to decide
3615
+ * whether an ancestor's rules apply.
2893
3616
  */
2894
3617
  get ParentChain() {
2895
3618
  if (this._parentChainCache !== null)
2896
3619
  return this._parentChainCache;
2897
3620
  const chain = [];
2898
3621
  let current = this.ParentEntityInfo;
3622
+ let complete = !this.ParentID || current !== null;
2899
3623
  const visited = new Set(); // circular reference protection
2900
3624
  while (current) {
2901
3625
  if (visited.has(current.ID))
2902
3626
  break; // prevent infinite loop
2903
3627
  visited.add(current.ID);
2904
3628
  chain.push(current);
2905
- current = current.ParentEntityInfo;
3629
+ const next = current.ParentEntityInfo;
3630
+ if (current.ParentID && !next) {
3631
+ complete = false;
3632
+ }
3633
+ current = next;
3634
+ }
3635
+ if (complete) {
3636
+ this._parentChainCache = chain;
2906
3637
  }
2907
- this._parentChainCache = chain;
2908
3638
  return chain;
2909
3639
  }
2910
3640
  /**
@@ -3048,23 +3778,21 @@ export class EntityInfo extends BaseInfo {
3048
3778
  */
3049
3779
  GetUserPermisions(user) {
3050
3780
  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.
3781
+ const _ = user.UserRoles; // preserve throw-on-null behavior
3782
+ const userRoleIds = getUserRoleIdSet(user);
3059
3783
  const allow = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
3060
3784
  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;
3785
+ if (userRoleIds.size > 0) {
3786
+ for (let j = 0; j < this.Permissions.length; j++) {
3787
+ const ep = this.Permissions[j];
3788
+ if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) {
3789
+ const bucket = ep.IsDeny ? deny : allow;
3790
+ bucket.CanCreate = bucket.CanCreate || !!ep.CanCreate;
3791
+ bucket.CanRead = bucket.CanRead || !!ep.CanRead;
3792
+ bucket.CanUpdate = bucket.CanUpdate || !!ep.CanUpdate;
3793
+ bucket.CanDelete = bucket.CanDelete || !!ep.CanDelete;
3794
+ }
3795
+ }
3068
3796
  }
3069
3797
  // An API-driven action also requires that the entity ALLOWS that action at all
3070
3798
  // (`Allow*API`). No role grant can create/update/delete records of an entity whose
@@ -3084,6 +3812,45 @@ export class EntityInfo extends BaseInfo {
3084
3812
  return null;
3085
3813
  }
3086
3814
  }
3815
+ /**
3816
+ * Checks whether the specified user has the requested permission (`Read`, `Create`, `Update`, or `Delete`)
3817
+ * on this entity. When `this.Permissions` is empty/uninitialized (e.g., in lightweight unit-test fixtures),
3818
+ * returns `true` so unconfigured fixtures are not blocked.
3819
+ */
3820
+ CheckPermissions(user, type, throwError = false) {
3821
+ if (!user || !this.Permissions || this.Permissions.length === 0) {
3822
+ return true;
3823
+ }
3824
+ const perms = this.GetUserPermisions(user);
3825
+ if (!perms) {
3826
+ if (throwError) {
3827
+ throw new Error(`Permission check failed for user on entity ${this.Name}`);
3828
+ }
3829
+ return false;
3830
+ }
3831
+ let allowed = false;
3832
+ switch (type) {
3833
+ case EntityPermissionType.Read:
3834
+ allowed = perms.CanRead;
3835
+ break;
3836
+ case EntityPermissionType.Create:
3837
+ allowed = perms.CanCreate;
3838
+ break;
3839
+ case EntityPermissionType.Update:
3840
+ allowed = perms.CanUpdate;
3841
+ break;
3842
+ case EntityPermissionType.Delete:
3843
+ allowed = perms.CanDelete;
3844
+ break;
3845
+ default:
3846
+ allowed = false;
3847
+ break;
3848
+ }
3849
+ if (!allowed && throwError) {
3850
+ throw new Error(`User does not have ${type} permission on ${this.Name}`);
3851
+ }
3852
+ return allowed;
3853
+ }
3087
3854
  /**
3088
3855
  * Determines if a given user, for a given permission type, is exempt from RowLevelSecurity or not.
3089
3856
  *
@@ -3099,13 +3866,17 @@ export class EntityInfo extends BaseInfo {
3099
3866
  * @returns
3100
3867
  */
3101
3868
  UserExemptFromRowLevelSecurity(user, type) {
3869
+ const _ = user.UserRoles; // preserve throw-on-null behavior if user is null/undefined
3870
+ const userRoleIds = getUserRoleIdSet(user);
3871
+ if (userRoleIds.size === 0) {
3872
+ return false;
3873
+ }
3102
3874
  for (let j = 0; j < this.Permissions.length; j++) {
3103
3875
  const ep = this.Permissions[j];
3104
3876
  if (ep.IsDeny) {
3105
3877
  continue; // a Deny row's Can* flags are denials, never grants — it cannot exempt anyone
3106
3878
  }
3107
- const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
3108
- if (roleMatch) { // user has this role
3879
+ if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) { // user has this role
3109
3880
  switch (type) {
3110
3881
  case EntityPermissionType.Create:
3111
3882
  if (ep.CanCreate && !ep.CreateRLSFilterID)
@@ -3146,14 +3917,18 @@ export class EntityInfo extends BaseInfo {
3146
3917
  * @returns
3147
3918
  */
3148
3919
  GetUserRowLevelSecurityInfo(user, type) {
3920
+ const _ = user.UserRoles; // preserve throw-on-null behavior if user is null/undefined
3149
3921
  const rlsList = [];
3922
+ const userRoleIds = getUserRoleIdSet(user);
3923
+ if (userRoleIds.size === 0) {
3924
+ return rlsList;
3925
+ }
3150
3926
  for (let j = 0; j < this.Permissions.length; j++) {
3151
3927
  const ep = this.Permissions[j];
3152
3928
  if (ep.IsDeny) {
3153
3929
  continue; // never a grant — see the doc comment
3154
3930
  }
3155
- const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
3156
- if (roleMatch) { // user has this role
3931
+ if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) { // user has this role
3157
3932
  let matchObject = null;
3158
3933
  switch (type) {
3159
3934
  case EntityPermissionType.Create:
@@ -3269,7 +4044,7 @@ export class EntityInfo extends BaseInfo {
3269
4044
  const clauses = [...mine]
3270
4045
  .sort((a, b) => a.FilterID.localeCompare(b.FilterID)) // deterministic order for the cache fingerprint (INV-2)
3271
4046
  .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
4047
+ const filter = MetadataProviderFor(this)?.RowLevelSecurityFilters?.find(f => UUIDsEqual(f.ID, b.FilterID));
3273
4048
  if (!filter) {
3274
4049
  LogError(`API-key row filter ${b.FilterID} for entity ${this.Name} not found in metadata — resolving to (1=0)`);
3275
4050
  return '(1=0)';
@@ -3441,7 +4216,7 @@ export class EntityInfo extends BaseInfo {
3441
4216
  const conditions = [];
3442
4217
  // Resolve the spoke entity's own organic key (matching by Name) to pull its
3443
4218
  // per-column normalization. Falls back to the hub's expression if not found.
3444
- const spokeOrganicKey = EntityInfo.resolveSpokeOrganicKey(relatedEntity, organicKey);
4219
+ const spokeOrganicKey = EntityInfo.resolveSpokeOrganicKey(relatedEntity, organicKey, record.EntityInfo?.OwningProvider);
3445
4220
  for (let i = 0; i < matchFields.length; i++) {
3446
4221
  const value = record.Get(matchFields[i]);
3447
4222
  if (value == null) {
@@ -3565,7 +4340,21 @@ export class EntityInfo extends BaseInfo {
3565
4340
  default: return `'${escapedValue}'`;
3566
4341
  }
3567
4342
  }
3568
- constructor(initData = null) {
4343
+ static { this.CHILD_INIT_KEYS = new Set([
4344
+ 'EntityFields', '_Fields', 'Fields',
4345
+ 'EntityPermissions', '_Permissions', 'Permissions',
4346
+ 'EntitySettings', '_Settings', 'Settings',
4347
+ 'EntityRelationships', '_RelatedEntities', 'RelatedEntities',
4348
+ 'EntityOrganicKeys', '_OrganicKeys', 'OrganicKeys',
4349
+ 'EntityAlternateBaseViews', '_AlternateBaseViews', 'AlternateBaseViews',
4350
+ ]); }
4351
+ /**
4352
+ * @param initData - the raw entity row (or another EntityInfo being re-instantiated)
4353
+ * @param md - the provider that owns this metadata. Sibling lookups ({@link ParentEntityInfo},
4354
+ * {@link ChildEntities}, RLS filters) resolve through it rather than the global provider, so
4355
+ * they stay correct in a multi-provider process. Optional for backward compatibility.
4356
+ */
4357
+ constructor(initData = null, md) {
3569
4358
  super();
3570
4359
  /**
3571
4360
  * Unique identifier for the entity
@@ -3715,19 +4504,7 @@ export class EntityInfo extends BaseInfo {
3715
4504
  * client-side IndexedDB cache. Zero overhead on hot save/query paths.
3716
4505
  */
3717
4506
  this.AllowCaching = false;
3718
- /**
3719
- * Whether field-level (column-level) security is enforced for this entity.
3720
- *
3721
- * This is the single gate every field-security enforcement point checks first, and it is
3722
- * explicit — never inferred from whether permission rows happen to exist. It is `false` for
3723
- * nearly every entity in nearly every deployment, so enforcement collapses to one boolean
3724
- * test: no field iteration, no aggregation, no allocation.
3725
- *
3726
- * Turning it on snapshots the entity's existing entity-level permissions into per-field
3727
- * rows, so enabling changes no behavior until an administrator tightens a field. Turning it
3728
- * off leaves the rows in place, inactive, so re-enabling does not lose the configuration.
3729
- */
3730
- this.EnableFieldLevelSecurity = false;
4507
+ this._enableFieldLevelSecurity = false;
3731
4508
  /**
3732
4509
  * Whether this entity is available through the GraphQL API
3733
4510
  */
@@ -4019,6 +4796,7 @@ export class EntityInfo extends BaseInfo {
4019
4796
  this.ParentBaseView = null;
4020
4797
  this._fieldCategories = null;
4021
4798
  this._organicKeys = [];
4799
+ this._alternateBaseViews = [];
4022
4800
  this.HasIdField = false;
4023
4801
  this.VirtualCount = 0;
4024
4802
  this.ManyToManyCount = 0;
@@ -4039,14 +4817,20 @@ export class EntityInfo extends BaseInfo {
4039
4817
  this._nameFieldCache = undefined;
4040
4818
  /** Memoized computed plural, keyed by the display name it was derived from (see `DisplayNamePlural`). */
4041
4819
  this._displayNamePluralCache = undefined;
4820
+ /** See {@link liveAncestors}. A `_` field with no matching getter, so `toJSON()` leaves it out. */
4821
+ this._liveAncestorsMemo = null;
4042
4822
  this._hasInactiveFields = undefined;
4043
4823
  this._hasSearchFields = undefined;
4044
4824
  // Cache for ParentChain to avoid repeated walks
4045
4825
  this._parentChainCache = null;
4046
4826
  // Cache for ParentEntityFieldNames
4047
4827
  this._parentEntityFieldNamesCache = null;
4828
+ // A new entity can replace one that memoized field-security answers point at (a metadata
4829
+ // refresh, an IS-A ancestor rebuilt), so building one moves the generation.
4830
+ advanceFieldSecurityGeneration();
4831
+ this.SetOwningProvider(md);
4048
4832
  if (initData) {
4049
- this.copyInitData(initData);
4833
+ this.copyInitData(initData, EntityInfo.CHILD_INIT_KEYS);
4050
4834
  // do some special handling to create class instances instead of just data objects
4051
4835
  // copy the Entity Fields (accept EntityFields, _Fields, or Fields as input names)
4052
4836
  this._fields = [];
@@ -4077,7 +4861,9 @@ export class EntityInfo extends BaseInfo {
4077
4861
  const ef = initData.EntityFields || initData._Fields || initData.Fields;
4078
4862
  if (ef) {
4079
4863
  for (let j = 0; j < ef.length; j++) {
4080
- this._fields.push(new EntityFieldInfo(ef[j]));
4864
+ const field = new EntityFieldInfo(ef[j]);
4865
+ field.SetOwningProvider(md);
4866
+ this._fields.push(field);
4081
4867
  }
4082
4868
  }
4083
4869
  // copy the Entity Permissions
@@ -4085,7 +4871,7 @@ export class EntityInfo extends BaseInfo {
4085
4871
  const ep = initData.EntityPermissions || initData._Permissions || initData.Permissions;
4086
4872
  if (ep) {
4087
4873
  for (let j = 0; j < ep.length; j++) {
4088
- this._permissions.push(new EntityPermissionInfo(ep[j]));
4874
+ this._permissions.push(new EntityPermissionInfo(ep[j], md));
4089
4875
  }
4090
4876
  }
4091
4877
  // copy the Entity settings
@@ -4131,6 +4917,22 @@ export class EntityInfo extends BaseInfo {
4131
4917
  this._organicKeys.push(new EntityOrganicKeyInfo(item));
4132
4918
  }
4133
4919
  }
4920
+ // copy the Alternate Base Views (sorted by Sequence, Name, ID, then linked via ParentID)
4921
+ this._alternateBaseViews = [];
4922
+ const abv = initData.EntityAlternateBaseViews || initData._AlternateBaseViews || initData.AlternateBaseViews;
4923
+ if (abv && Array.isArray(abv)) {
4924
+ for (const item of abv) {
4925
+ const viewInfo = new EntityAlternateBaseViewInfo(item, this.SchemaName);
4926
+ viewInfo.SetOwningProvider(md);
4927
+ this._alternateBaseViews.push(viewInfo);
4928
+ }
4929
+ this._alternateBaseViews.sort((a, b) => {
4930
+ const aSeq = a.Sequence !== null && a.Sequence !== undefined ? a.Sequence : 0;
4931
+ const bSeq = b.Sequence !== null && b.Sequence !== undefined ? b.Sequence : 0;
4932
+ return (aSeq - bSeq) || ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID);
4933
+ });
4934
+ EntityAlternateBaseViewInfo.LinkHierarchy(this._alternateBaseViews, this.SchemaName);
4935
+ }
4134
4936
  this.prepareSpecialFields();
4135
4937
  }
4136
4938
  }