@memberjunction/core 6.2.0-edge.5 → 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 (121) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +15 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +21 -0
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/alternateBaseViews.d.ts +119 -0
  6. package/dist/generic/alternateBaseViews.d.ts.map +1 -0
  7. package/dist/generic/alternateBaseViews.js +223 -0
  8. package/dist/generic/alternateBaseViews.js.map +1 -0
  9. package/dist/generic/baseEngine.d.ts +53 -9
  10. package/dist/generic/baseEngine.d.ts.map +1 -1
  11. package/dist/generic/baseEngine.js +194 -35
  12. package/dist/generic/baseEngine.js.map +1 -1
  13. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  14. package/dist/generic/baseEngineRegistry.js +2 -1
  15. package/dist/generic/baseEngineRegistry.js.map +1 -1
  16. package/dist/generic/baseEntity.d.ts +153 -17
  17. package/dist/generic/baseEntity.d.ts.map +1 -1
  18. package/dist/generic/baseEntity.js +345 -46
  19. package/dist/generic/baseEntity.js.map +1 -1
  20. package/dist/generic/contextTokens.d.ts +136 -0
  21. package/dist/generic/contextTokens.d.ts.map +1 -0
  22. package/dist/generic/contextTokens.js +319 -0
  23. package/dist/generic/contextTokens.js.map +1 -0
  24. package/dist/generic/databaseProviderBase.d.ts +66 -5
  25. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  26. package/dist/generic/databaseProviderBase.js +159 -12
  27. package/dist/generic/databaseProviderBase.js.map +1 -1
  28. package/dist/generic/embeddedRecord.d.ts +6 -0
  29. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  30. package/dist/generic/embeddedRecord.js +15 -1
  31. package/dist/generic/embeddedRecord.js.map +1 -1
  32. package/dist/generic/entityCompanion.d.ts +7 -0
  33. package/dist/generic/entityCompanion.d.ts.map +1 -1
  34. package/dist/generic/entityCompanion.js +8 -0
  35. package/dist/generic/entityCompanion.js.map +1 -1
  36. package/dist/generic/entityInfo.d.ts +228 -9
  37. package/dist/generic/entityInfo.d.ts.map +1 -1
  38. package/dist/generic/entityInfo.js +574 -65
  39. package/dist/generic/entityInfo.js.map +1 -1
  40. package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
  41. package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
  42. package/dist/generic/entityValueFieldSecurity.js +250 -0
  43. package/dist/generic/entityValueFieldSecurity.js.map +1 -0
  44. package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
  45. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
  46. package/dist/generic/fieldSecuritySQLGate.js +898 -0
  47. package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
  48. package/dist/generic/indexRestrictedFields.d.ts +47 -0
  49. package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
  50. package/dist/generic/indexRestrictedFields.js +129 -0
  51. package/dist/generic/indexRestrictedFields.js.map +1 -0
  52. package/dist/generic/interfaces.d.ts +14 -1
  53. package/dist/generic/interfaces.d.ts.map +1 -1
  54. package/dist/generic/interfaces.js.map +1 -1
  55. package/dist/generic/localCacheManager.d.ts +80 -11
  56. package/dist/generic/localCacheManager.d.ts.map +1 -1
  57. package/dist/generic/localCacheManager.js +209 -38
  58. package/dist/generic/localCacheManager.js.map +1 -1
  59. package/dist/generic/logging.d.ts.map +1 -1
  60. package/dist/generic/logging.js +7 -1
  61. package/dist/generic/logging.js.map +1 -1
  62. package/dist/generic/metadata.d.ts +3 -2
  63. package/dist/generic/metadata.d.ts.map +1 -1
  64. package/dist/generic/metadata.js +5 -2
  65. package/dist/generic/metadata.js.map +1 -1
  66. package/dist/generic/platformSQL.d.ts +54 -0
  67. package/dist/generic/platformSQL.d.ts.map +1 -1
  68. package/dist/generic/platformSQL.js +55 -0
  69. package/dist/generic/platformSQL.js.map +1 -1
  70. package/dist/generic/providerBase.d.ts +254 -56
  71. package/dist/generic/providerBase.d.ts.map +1 -1
  72. package/dist/generic/providerBase.js +698 -195
  73. package/dist/generic/providerBase.js.map +1 -1
  74. package/dist/generic/queryInfo.d.ts +13 -0
  75. package/dist/generic/queryInfo.d.ts.map +1 -1
  76. package/dist/generic/queryInfo.js +13 -0
  77. package/dist/generic/queryInfo.js.map +1 -1
  78. package/dist/generic/queryInfoInterfaces.d.ts +8 -0
  79. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  80. package/dist/generic/queryTemplateContext.d.ts +49 -0
  81. package/dist/generic/queryTemplateContext.d.ts.map +1 -0
  82. package/dist/generic/queryTemplateContext.js +113 -0
  83. package/dist/generic/queryTemplateContext.js.map +1 -0
  84. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
  85. package/dist/generic/recordChangeFieldSecurity.js +13 -2
  86. package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
  87. package/dist/generic/relatedRecordCollection.d.ts +13 -0
  88. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  89. package/dist/generic/relatedRecordCollection.js +44 -5
  90. package/dist/generic/relatedRecordCollection.js.map +1 -1
  91. package/dist/generic/runQuery.d.ts +42 -0
  92. package/dist/generic/runQuery.d.ts.map +1 -1
  93. package/dist/generic/runQuery.js +14 -0
  94. package/dist/generic/runQuery.js.map +1 -1
  95. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  96. package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
  97. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  98. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  99. package/dist/generic/saveEntityGraphOperation.js +3 -0
  100. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  101. package/dist/generic/securityInfo.d.ts +11 -0
  102. package/dist/generic/securityInfo.d.ts.map +1 -1
  103. package/dist/generic/securityInfo.js +15 -0
  104. package/dist/generic/securityInfo.js.map +1 -1
  105. package/dist/generic/telemetryManager.d.ts +9 -0
  106. package/dist/generic/telemetryManager.d.ts.map +1 -1
  107. package/dist/generic/telemetryManager.js +5 -1
  108. package/dist/generic/telemetryManager.js.map +1 -1
  109. package/dist/index.d.ts +7 -0
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +7 -0
  112. package/dist/index.js.map +1 -1
  113. package/dist/views/runView.d.ts +89 -0
  114. package/dist/views/runView.d.ts.map +1 -1
  115. package/dist/views/runView.js +36 -0
  116. package/dist/views/runView.js.map +1 -1
  117. package/dist/views/viewProperties.d.ts +55 -0
  118. package/dist/views/viewProperties.d.ts.map +1 -0
  119. package/dist/views/viewProperties.js +258 -0
  120. package/dist/views/viewProperties.js.map +1 -0
  121. package/package.json +3 -3
@@ -4,7 +4,7 @@ import { MetadataProviderFor } from "./infoProviderResolution.js";
4
4
  import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, SQLMaxByteLength, FormatValue, CodeNameFromString } from "./util.js";
5
5
  import { IsFixedWidthStringSQLType } from "@memberjunction/sql-dialect";
6
6
  import { LogError } from "./logging.js";
7
- import { WarningManager, SafeJSONParse, UUIDsEqual, ordinalCompare, GeneratePluralName } from "@memberjunction/global";
7
+ import { WarningManager, SafeJSONParse, UUIDsEqual, NormalizeUUID, ordinalCompare, GeneratePluralName } from "@memberjunction/global";
8
8
  import { ReadRelationshipJoinFields, } from "./entityConfiguration.js";
9
9
  /**
10
10
  * Runtime domain for {@link EntityFieldInfo.ExtendedType}. This array is the single source of
@@ -306,6 +306,186 @@ export class EntityOrganicKeyRelatedEntityInfo extends BaseInfo {
306
306
  }
307
307
  }
308
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
+ }
309
489
  export const EntityPermissionType = {
310
490
  Read: 'Read',
311
491
  Create: 'Create',
@@ -463,9 +643,16 @@ export const ReadableFieldsTransportKey = 'ReadableFields___';
463
643
  * set and aggregate it — see {@link EntityFieldInfo.AggregateFieldRulesForUser}.
464
644
  */
465
645
  export function IsRestrictingFieldRule(rule) {
466
- return (rule?.ReadAccess === FieldPermissionAccess.Deny ||
467
- rule?.UpdateAccess === FieldPermissionAccess.Deny ||
468
- 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';
469
656
  }
470
657
  /**
471
658
  * The single wording for "you cannot use this field", modelled on SQL Server's posture of never
@@ -2228,6 +2415,64 @@ function getUserRoleIdSet(user) {
2228
2415
  }
2229
2416
  const _entityFieldIndexCache = new WeakMap();
2230
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++;
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
+ }
2231
2476
  const _entityChildEntitiesCache = new WeakMap();
2232
2477
  /**
2233
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.
@@ -2354,6 +2599,28 @@ export class EntityInfo extends BaseInfo {
2354
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.`);
2355
2600
  return false;
2356
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
+ }
2357
2624
  /**
2358
2625
  * True when at least one field is a writable Geo* source (street and/or native lat/lng).
2359
2626
  * Person/Org PrimaryAddress* are virtual display fields and do **not** count.
@@ -2535,6 +2802,87 @@ export class EntityInfo extends BaseInfo {
2535
2802
  set _floatCount(value) {
2536
2803
  this.FloatCount = value;
2537
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
+ }
2538
2886
  /**
2539
2887
  * The set of field names this user may NOT READ on this entity — the per-request primitive
2540
2888
  * every field-security enforcement point is built on.
@@ -2545,8 +2893,9 @@ export class EntityInfo extends BaseInfo {
2545
2893
  * re-scans `user.UserRoles` and allocates. A Set lookup costs neither.
2546
2894
  *
2547
2895
  * Names are lowercased so callers can match case-insensitively, consistent with
2548
- * {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when the entity has
2549
- * 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
2550
2899
  * `size === 0` as the single "nothing to do" condition.
2551
2900
  */
2552
2901
  GetDeniedReadFields(user) {
@@ -2566,10 +2915,10 @@ export class EntityInfo extends BaseInfo {
2566
2915
  * The caller still decides what to render in place of the value, which is the part that
2567
2916
  * differs per surface and should not be hidden inside a getter.
2568
2917
  *
2569
- * **Fails open** on a missing user, a missing field name, or an entity with field security
2570
- * switched off — matching `BaseEntity`'s own gate and `MjFormFieldComponent`. The server is
2571
- * the real boundary; a UI that blanked out fields because no user had resolved yet would be
2572
- * 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.
2573
2922
  *
2574
2923
  * PERFORMANCE: this delegates to {@link GetDeniedReadFields}, which walks every field on the
2575
2924
  * entity. Fine for the handful of chrome reads it exists for; do NOT call it per row in a
@@ -2579,7 +2928,7 @@ export class EntityInfo extends BaseInfo {
2579
2928
  * @param user the acting user
2580
2929
  */
2581
2930
  IsFieldReadableByUser(fieldName, user) {
2582
- if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2931
+ if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
2583
2932
  return true;
2584
2933
  }
2585
2934
  return !this.GetDeniedReadFields(user).has(fieldName.trim().toLowerCase());
@@ -2591,7 +2940,7 @@ export class EntityInfo extends BaseInfo {
2591
2940
  * three conditions.
2592
2941
  */
2593
2942
  IsFieldUpdatableByUser(fieldName, user) {
2594
- if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2943
+ if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
2595
2944
  return true;
2596
2945
  }
2597
2946
  return !this.GetDeniedUpdateFields(user).has(fieldName.trim().toLowerCase());
@@ -2605,7 +2954,7 @@ export class EntityInfo extends BaseInfo {
2605
2954
  * the user typed, which is the case this exists to prevent.
2606
2955
  */
2607
2956
  IsFieldCreatableByUser(fieldName, user) {
2608
- if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2957
+ if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
2609
2958
  return true;
2610
2959
  }
2611
2960
  return !this.GetDeniedCreateFields(user).has(fieldName.trim().toLowerCase());
@@ -2631,51 +2980,67 @@ export class EntityInfo extends BaseInfo {
2631
2980
  GetDeniedCreateFields(user) {
2632
2981
  return this.getCachedDeniedFields(user, 'create', (p) => !p.CanCreate);
2633
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
+ */
2634
3005
  getCachedDeniedFields(user, verb, isDenied) {
2635
- if (!this.EnableFieldLevelSecurity) {
2636
- return new Set();
3006
+ if (!this.FieldLevelSecurityApplies) {
3007
+ return new Set(); // gate on inheritance too: an ancestor's rules still apply
2637
3008
  }
2638
3009
  if (!user || typeof user !== 'object') {
2639
- return this.getDeniedFields(user, isDenied);
3010
+ return this.getDeniedFields(user, verb, isDenied);
2640
3011
  }
2641
- let totalPerms = 0;
2642
- for (let i = 0; i < this._fields.length; i++) {
2643
- totalPerms += this._fields[i].FieldPermissions.length;
3012
+ const entry = this.deniedFieldsMemoEntryFor(user);
3013
+ const existing = entry[verb];
3014
+ if (existing !== undefined) {
3015
+ return existing;
2644
3016
  }
2645
- const rolesRef = user.UserRoles;
2646
- const rolesLen = rolesRef?.length ?? 0;
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);
2647
3026
  let byUser = _entityDeniedFieldsCache.get(this);
2648
3027
  if (!byUser) {
2649
3028
  byUser = new WeakMap();
2650
3029
  _entityDeniedFieldsCache.set(this, byUser);
2651
3030
  }
2652
3031
  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
- };
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 };
2666
3035
  byUser.set(user, entry);
2667
3036
  }
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;
3037
+ return entry;
2675
3038
  }
2676
3039
  /**
2677
3040
  * Shared walk behind {@link GetDeniedReadFields} / {@link GetDeniedUpdateFields} /
2678
- * {@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}).
2679
3044
  *
2680
3045
  * Every field is aggregated, including those carrying no permission records — on an enabled
2681
3046
  * entity those are denied. Unrestrictable fields (primary keys, `__mj_` columns) come back
@@ -2698,18 +3063,54 @@ export class EntityInfo extends BaseInfo {
2698
3063
  * that collide on `CodeName` already collide on their generated property, which is not a
2699
3064
  * schema CodeGen can emit.
2700
3065
  */
2701
- getDeniedFields(user, isDenied) {
3066
+ getDeniedFields(user, verb, isDenied) {
2702
3067
  const denied = new Set();
2703
- if (!this.EnableFieldLevelSecurity) {
2704
- 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;
2705
3102
  }
2706
3103
  for (const field of this._fields) {
2707
- if (isDenied(field.GetUserFieldPermissions(user, true))) {
2708
- denied.add(field.Name.trim().toLowerCase());
2709
- 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);
2710
3107
  }
2711
3108
  }
2712
- 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());
2713
3114
  }
2714
3115
  /**
2715
3116
  * O(1) case-insensitive field lookup by name. Use this instead of `Fields.find(f => f.Name === name)`
@@ -2970,6 +3371,53 @@ export class EntityInfo extends BaseInfo {
2970
3371
  get OrganicKeys() {
2971
3372
  return this._organicKeys;
2972
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
+ }
2973
3421
  static { this.__createdAtFieldName = '__mj_CreatedAt'; }
2974
3422
  static { this.__updatedAtFieldName = '__mj_UpdatedAt'; }
2975
3423
  static { this.__deletedAtFieldName = '__mj_DeletedAt'; }
@@ -3160,22 +3608,33 @@ export class EntityInfo extends BaseInfo {
3160
3608
  * Walks the IS-A chain upward from this entity to the root, returning all parent entities.
3161
3609
  * Does NOT include this entity itself.
3162
3610
  * Example: For Webinars (IS-A Meetings IS-A Products), returns [Meetings, Products].
3163
- * 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.
3164
3616
  */
3165
3617
  get ParentChain() {
3166
3618
  if (this._parentChainCache !== null)
3167
3619
  return this._parentChainCache;
3168
3620
  const chain = [];
3169
3621
  let current = this.ParentEntityInfo;
3622
+ let complete = !this.ParentID || current !== null;
3170
3623
  const visited = new Set(); // circular reference protection
3171
3624
  while (current) {
3172
3625
  if (visited.has(current.ID))
3173
3626
  break; // prevent infinite loop
3174
3627
  visited.add(current.ID);
3175
3628
  chain.push(current);
3176
- 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;
3177
3637
  }
3178
- this._parentChainCache = chain;
3179
3638
  return chain;
3180
3639
  }
3181
3640
  /**
@@ -3353,6 +3812,45 @@ export class EntityInfo extends BaseInfo {
3353
3812
  return null;
3354
3813
  }
3355
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
+ }
3356
3854
  /**
3357
3855
  * Determines if a given user, for a given permission type, is exempt from RowLevelSecurity or not.
3358
3856
  *
@@ -3848,6 +4346,7 @@ export class EntityInfo extends BaseInfo {
3848
4346
  'EntitySettings', '_Settings', 'Settings',
3849
4347
  'EntityRelationships', '_RelatedEntities', 'RelatedEntities',
3850
4348
  'EntityOrganicKeys', '_OrganicKeys', 'OrganicKeys',
4349
+ 'EntityAlternateBaseViews', '_AlternateBaseViews', 'AlternateBaseViews',
3851
4350
  ]); }
3852
4351
  /**
3853
4352
  * @param initData - the raw entity row (or another EntityInfo being re-instantiated)
@@ -4005,19 +4504,7 @@ export class EntityInfo extends BaseInfo {
4005
4504
  * client-side IndexedDB cache. Zero overhead on hot save/query paths.
4006
4505
  */
4007
4506
  this.AllowCaching = false;
4008
- /**
4009
- * Whether field-level (column-level) security is enforced for this entity.
4010
- *
4011
- * This is the single gate every field-security enforcement point checks first, and it is
4012
- * explicit — never inferred from whether permission rows happen to exist. It is `false` for
4013
- * nearly every entity in nearly every deployment, so enforcement collapses to one boolean
4014
- * test: no field iteration, no aggregation, no allocation.
4015
- *
4016
- * Turning it on snapshots the entity's existing entity-level permissions into per-field
4017
- * rows, so enabling changes no behavior until an administrator tightens a field. Turning it
4018
- * off leaves the rows in place, inactive, so re-enabling does not lose the configuration.
4019
- */
4020
- this.EnableFieldLevelSecurity = false;
4507
+ this._enableFieldLevelSecurity = false;
4021
4508
  /**
4022
4509
  * Whether this entity is available through the GraphQL API
4023
4510
  */
@@ -4309,6 +4796,7 @@ export class EntityInfo extends BaseInfo {
4309
4796
  this.ParentBaseView = null;
4310
4797
  this._fieldCategories = null;
4311
4798
  this._organicKeys = [];
4799
+ this._alternateBaseViews = [];
4312
4800
  this.HasIdField = false;
4313
4801
  this.VirtualCount = 0;
4314
4802
  this.ManyToManyCount = 0;
@@ -4329,12 +4817,17 @@ export class EntityInfo extends BaseInfo {
4329
4817
  this._nameFieldCache = undefined;
4330
4818
  /** Memoized computed plural, keyed by the display name it was derived from (see `DisplayNamePlural`). */
4331
4819
  this._displayNamePluralCache = undefined;
4820
+ /** See {@link liveAncestors}. A `_` field with no matching getter, so `toJSON()` leaves it out. */
4821
+ this._liveAncestorsMemo = null;
4332
4822
  this._hasInactiveFields = undefined;
4333
4823
  this._hasSearchFields = undefined;
4334
4824
  // Cache for ParentChain to avoid repeated walks
4335
4825
  this._parentChainCache = null;
4336
4826
  // Cache for ParentEntityFieldNames
4337
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();
4338
4831
  this.SetOwningProvider(md);
4339
4832
  if (initData) {
4340
4833
  this.copyInitData(initData, EntityInfo.CHILD_INIT_KEYS);
@@ -4424,6 +4917,22 @@ export class EntityInfo extends BaseInfo {
4424
4917
  this._organicKeys.push(new EntityOrganicKeyInfo(item));
4425
4918
  }
4426
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
+ }
4427
4936
  this.prepareSpecialFields();
4428
4937
  }
4429
4938
  }