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

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 (148) 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/EntityFieldRules.d.ts.map +1 -1
  6. package/dist/generic/EntityFieldRules.js +1 -0
  7. package/dist/generic/EntityFieldRules.js.map +1 -1
  8. package/dist/generic/alternateBaseViews.d.ts +119 -0
  9. package/dist/generic/alternateBaseViews.d.ts.map +1 -0
  10. package/dist/generic/alternateBaseViews.js +223 -0
  11. package/dist/generic/alternateBaseViews.js.map +1 -0
  12. package/dist/generic/baseEngine.d.ts +171 -17
  13. package/dist/generic/baseEngine.d.ts.map +1 -1
  14. package/dist/generic/baseEngine.js +722 -117
  15. package/dist/generic/baseEngine.js.map +1 -1
  16. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  17. package/dist/generic/baseEngineRegistry.js +2 -1
  18. package/dist/generic/baseEngineRegistry.js.map +1 -1
  19. package/dist/generic/baseEntity.d.ts +216 -21
  20. package/dist/generic/baseEntity.d.ts.map +1 -1
  21. package/dist/generic/baseEntity.js +969 -100
  22. package/dist/generic/baseEntity.js.map +1 -1
  23. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  24. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  25. package/dist/generic/baseRemotableOperation.js +112 -8
  26. package/dist/generic/baseRemotableOperation.js.map +1 -1
  27. package/dist/generic/contextTokens.d.ts +136 -0
  28. package/dist/generic/contextTokens.d.ts.map +1 -0
  29. package/dist/generic/contextTokens.js +319 -0
  30. package/dist/generic/contextTokens.js.map +1 -0
  31. package/dist/generic/databaseProviderBase.d.ts +103 -5
  32. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  33. package/dist/generic/databaseProviderBase.js +381 -52
  34. package/dist/generic/databaseProviderBase.js.map +1 -1
  35. package/dist/generic/embeddedRecord.d.ts +6 -0
  36. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  37. package/dist/generic/embeddedRecord.js +15 -1
  38. package/dist/generic/embeddedRecord.js.map +1 -1
  39. package/dist/generic/entityCompanion.d.ts +7 -0
  40. package/dist/generic/entityCompanion.d.ts.map +1 -1
  41. package/dist/generic/entityCompanion.js +8 -0
  42. package/dist/generic/entityCompanion.js.map +1 -1
  43. package/dist/generic/entityInfo.d.ts +234 -9
  44. package/dist/generic/entityInfo.d.ts.map +1 -1
  45. package/dist/generic/entityInfo.js +592 -77
  46. package/dist/generic/entityInfo.js.map +1 -1
  47. package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
  48. package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
  49. package/dist/generic/entityValueFieldSecurity.js +250 -0
  50. package/dist/generic/entityValueFieldSecurity.js.map +1 -0
  51. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  52. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  53. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  54. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  55. package/dist/generic/fieldSecuritySQLGate.d.ts +359 -0
  56. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
  57. package/dist/generic/fieldSecuritySQLGate.js +902 -0
  58. package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
  59. package/dist/generic/indexRestrictedFields.d.ts +47 -0
  60. package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
  61. package/dist/generic/indexRestrictedFields.js +129 -0
  62. package/dist/generic/indexRestrictedFields.js.map +1 -0
  63. package/dist/generic/interfaces.d.ts +64 -2
  64. package/dist/generic/interfaces.d.ts.map +1 -1
  65. package/dist/generic/interfaces.js.map +1 -1
  66. package/dist/generic/listMembershipFilter.d.ts +5 -0
  67. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  68. package/dist/generic/listMembershipFilter.js +23 -0
  69. package/dist/generic/listMembershipFilter.js.map +1 -0
  70. package/dist/generic/localCacheManager.d.ts +126 -14
  71. package/dist/generic/localCacheManager.d.ts.map +1 -1
  72. package/dist/generic/localCacheManager.js +348 -48
  73. package/dist/generic/localCacheManager.js.map +1 -1
  74. package/dist/generic/logging.d.ts.map +1 -1
  75. package/dist/generic/logging.js +7 -1
  76. package/dist/generic/logging.js.map +1 -1
  77. package/dist/generic/metadata.d.ts +3 -2
  78. package/dist/generic/metadata.d.ts.map +1 -1
  79. package/dist/generic/metadata.js +5 -2
  80. package/dist/generic/metadata.js.map +1 -1
  81. package/dist/generic/platformSQL.d.ts +54 -0
  82. package/dist/generic/platformSQL.d.ts.map +1 -1
  83. package/dist/generic/platformSQL.js +55 -0
  84. package/dist/generic/platformSQL.js.map +1 -1
  85. package/dist/generic/providerBase.d.ts +408 -57
  86. package/dist/generic/providerBase.d.ts.map +1 -1
  87. package/dist/generic/providerBase.js +1051 -310
  88. package/dist/generic/providerBase.js.map +1 -1
  89. package/dist/generic/queryInfo.d.ts +13 -0
  90. package/dist/generic/queryInfo.d.ts.map +1 -1
  91. package/dist/generic/queryInfo.js +13 -0
  92. package/dist/generic/queryInfo.js.map +1 -1
  93. package/dist/generic/queryInfoInterfaces.d.ts +8 -0
  94. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  95. package/dist/generic/queryTemplateContext.d.ts +49 -0
  96. package/dist/generic/queryTemplateContext.d.ts.map +1 -0
  97. package/dist/generic/queryTemplateContext.js +113 -0
  98. package/dist/generic/queryTemplateContext.js.map +1 -0
  99. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
  100. package/dist/generic/recordChangeFieldSecurity.js +13 -2
  101. package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
  102. package/dist/generic/relatedRecordCollection.d.ts +18 -0
  103. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  104. package/dist/generic/relatedRecordCollection.js +55 -7
  105. package/dist/generic/relatedRecordCollection.js.map +1 -1
  106. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  107. package/dist/generic/remoteOperationDispatch.js +14 -2
  108. package/dist/generic/remoteOperationDispatch.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 +33 -6
  114. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  115. package/dist/generic/runQuerySQLFilterImplementations.js +119 -29
  116. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  117. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  118. package/dist/generic/saveEntityGraphOperation.js +3 -0
  119. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  120. package/dist/generic/securityInfo.d.ts +11 -0
  121. package/dist/generic/securityInfo.d.ts.map +1 -1
  122. package/dist/generic/securityInfo.js +15 -0
  123. package/dist/generic/securityInfo.js.map +1 -1
  124. package/dist/generic/telemetryManager.d.ts +9 -0
  125. package/dist/generic/telemetryManager.d.ts.map +1 -1
  126. package/dist/generic/telemetryManager.js +5 -1
  127. package/dist/generic/telemetryManager.js.map +1 -1
  128. package/dist/generic/transportError.d.ts +41 -0
  129. package/dist/generic/transportError.d.ts.map +1 -0
  130. package/dist/generic/transportError.js +167 -0
  131. package/dist/generic/transportError.js.map +1 -0
  132. package/dist/generic/util.d.ts +12 -0
  133. package/dist/generic/util.d.ts.map +1 -1
  134. package/dist/generic/util.js +52 -3
  135. package/dist/generic/util.js.map +1 -1
  136. package/dist/index.d.ts +9 -0
  137. package/dist/index.d.ts.map +1 -1
  138. package/dist/index.js +9 -0
  139. package/dist/index.js.map +1 -1
  140. package/dist/views/runView.d.ts +102 -0
  141. package/dist/views/runView.d.ts.map +1 -1
  142. package/dist/views/runView.js +40 -0
  143. package/dist/views/runView.js.map +1 -1
  144. package/dist/views/viewProperties.d.ts +55 -0
  145. package/dist/views/viewProperties.d.ts.map +1 -0
  146. package/dist/views/viewProperties.js +258 -0
  147. package/dist/views/viewProperties.js.map +1 -0
  148. package/package.json +3 -3
@@ -1,10 +1,10 @@
1
1
  import { BaseInfo } from "./baseInfo.js";
2
2
  import { Metadata } from "./metadata.js";
3
3
  import { MetadataProviderFor } from "./infoProviderResolution.js";
4
- import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, SQLMaxByteLength, FormatValue, CodeNameFromString } from "./util.js";
4
+ import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, SQLMaxByteLength, FormatValue, CodeNameFromString, CanonicalPrimaryKeyValue } 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
@@ -1508,6 +1695,14 @@ export class EntityFieldInfo extends BaseInfo {
1508
1695
  this._isUniqueIdentifierCache = t === 'uniqueidentifier' || t === 'uuid';
1509
1696
  return this._isUniqueIdentifierCache;
1510
1697
  }
1698
+ /**
1699
+ * Returns the canonical string lookup key for a value of this field when matching records
1700
+ * across heterogeneous sources (e.g., SQL Server upper-case vs. PostgreSQL lower-case UUIDs,
1701
+ * while preserving exact case for non-UUID string keys).
1702
+ */
1703
+ FormatCanonicalValue(value) {
1704
+ return CanonicalPrimaryKeyValue(String(value ?? ''), this.IsUniqueIdentifier);
1705
+ }
1511
1706
  /**
1512
1707
  * Returns true if the field's SQL type is **fixed-width / space-padded**
1513
1708
  * (SQL Server `char`/`nchar`, PostgreSQL `char`/`character`/`bpchar`).
@@ -2228,6 +2423,64 @@ function getUserRoleIdSet(user) {
2228
2423
  }
2229
2424
  const _entityFieldIndexCache = new WeakMap();
2230
2425
  const _entityDeniedFieldsCache = new WeakMap();
2426
+ /**
2427
+ * The field-security generation: one process-wide number that moves whenever an answer derived
2428
+ * from field-security configuration may have changed. It moves when an `EntityInfo` is built (a
2429
+ * metadata refresh rebuilds every entity), when an entity's {@link EntityInfo.EnableFieldLevelSecurity}
2430
+ * flag is assigned, and when {@link EntityInfo.InvalidateFieldSecurityCache} is called. Every memo
2431
+ * of a field-security answer records the number it was filled under and is discarded once the
2432
+ * number has moved, so a memo is never served across any of those changes. Numbers only grow.
2433
+ */
2434
+ let _fieldSecurityGeneration = 0;
2435
+ /** Marks every memoized field-security answer stale (see {@link CurrentFieldSecurityGeneration}). */
2436
+ function advanceFieldSecurityGeneration() {
2437
+ _fieldSecurityGeneration++;
2438
+ }
2439
+ /**
2440
+ * The current field-security generation. A memo of anything derived from field-security
2441
+ * configuration (denied sets, whether field security applies, a user's denial profile across a
2442
+ * catalog) is valid only while this returns the number the memo was filled under. It moves when an
2443
+ * `EntityInfo` is built, when an entity's `EnableFieldLevelSecurity` flag is assigned, and when
2444
+ * `EntityInfo.InvalidateFieldSecurityCache()` is called.
2445
+ */
2446
+ export function CurrentFieldSecurityGeneration() {
2447
+ return _fieldSecurityGeneration;
2448
+ }
2449
+ const NO_ANCESTORS = Object.freeze([]);
2450
+ /** Whether two ancestor lists hold the same entities, by identity, in the same order. */
2451
+ function sameAncestors(a, b) {
2452
+ if (a === b) {
2453
+ return true;
2454
+ }
2455
+ if (a.length !== b.length) {
2456
+ return false;
2457
+ }
2458
+ for (let i = 0; i < a.length; i++) {
2459
+ if (a[i] !== b[i]) {
2460
+ return false;
2461
+ }
2462
+ }
2463
+ return true;
2464
+ }
2465
+ /**
2466
+ * The role IDs a user holds, in order, as one string: changes whenever a role is added, removed
2467
+ * or swapped. Field-level security reads nothing else about a user, so this, with
2468
+ * {@link CurrentFieldSecurityGeneration}, keys any memo of a user's field-security answers.
2469
+ */
2470
+ export function FieldSecurityUserRolesKey(user) {
2471
+ return userRolesKey(user);
2472
+ }
2473
+ function userRolesKey(user) {
2474
+ const roles = user.UserRoles;
2475
+ if (!roles || roles.length === 0) {
2476
+ return '';
2477
+ }
2478
+ let key = '';
2479
+ for (let i = 0; i < roles.length; i++) {
2480
+ key += `${roles[i]?.RoleID ?? ''}|`;
2481
+ }
2482
+ return key;
2483
+ }
2231
2484
  const _entityChildEntitiesCache = new WeakMap();
2232
2485
  /**
2233
2486
  * 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 +2607,28 @@ export class EntityInfo extends BaseInfo {
2354
2607
  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
2608
  return false;
2356
2609
  }
2610
+ /**
2611
+ * Whether field-level (column-level) security is enforced for this entity.
2612
+ *
2613
+ * This is the single gate every field-security enforcement point checks first, and it is
2614
+ * explicit — never inferred from whether permission rows happen to exist. It is `false` for
2615
+ * nearly every entity in nearly every deployment, so enforcement collapses to one boolean
2616
+ * test: no field iteration, no aggregation, no allocation.
2617
+ *
2618
+ * Turning it on snapshots the entity's existing entity-level permissions into per-field
2619
+ * rows, so enabling changes no behavior until an administrator tightens a field. Turning it
2620
+ * off leaves the rows in place, inactive, so re-enabling does not lose the configuration.
2621
+ *
2622
+ * Assigning it moves the field-security generation ({@link CurrentFieldSecurityGeneration}),
2623
+ * so every memoized field-security answer that read the old value is recomputed.
2624
+ */
2625
+ get EnableFieldLevelSecurity() {
2626
+ return this._enableFieldLevelSecurity;
2627
+ }
2628
+ set EnableFieldLevelSecurity(value) {
2629
+ this._enableFieldLevelSecurity = value;
2630
+ advanceFieldSecurityGeneration();
2631
+ }
2357
2632
  /**
2358
2633
  * True when at least one field is a writable Geo* source (street and/or native lat/lng).
2359
2634
  * Person/Org PrimaryAddress* are virtual display fields and do **not** count.
@@ -2535,6 +2810,87 @@ export class EntityInfo extends BaseInfo {
2535
2810
  set _floatCount(value) {
2536
2811
  this.FloatCount = value;
2537
2812
  }
2813
+ /**
2814
+ * Whether field-level security decides anything on this entity: its own
2815
+ * {@link EnableFieldLevelSecurity} flag is on, or an IS-A ancestor's is.
2816
+ *
2817
+ * **Enforcement points gate on this, never on the raw flag.** An IS-A child carries each
2818
+ * ancestor's fields as mirror fields of its own, read from the same row. Gating on the child's
2819
+ * own flag — off on nearly every child, because nobody turns it on there — let a role denied
2820
+ * `Phone` on the parent read it through the child's RunView, load and `GetAll()`. The denied
2821
+ * sets ({@link GetDeniedReadFields} and siblings) carry the ancestor's denials for those mirror
2822
+ * fields; this is the matching gate, so a caller that skips work when it is false never skips
2823
+ * an ancestor's rules.
2824
+ *
2825
+ * The raw flag stays the right question for CONFIGURATION — which entities reconciliation,
2826
+ * CodeGen and the system-user sweeps cover — because the rules themselves live on the entity
2827
+ * that owns the field.
2828
+ */
2829
+ get FieldLevelSecurityApplies() {
2830
+ if (this.EnableFieldLevelSecurity) {
2831
+ return true; // checked first: one boolean for every entity that has it on
2832
+ }
2833
+ if (!this.ParentID) {
2834
+ return false; // one boolean for every entity outside an IS-A chain
2835
+ }
2836
+ // The ancestors are memoized; their flags are read live, so an in-place flip is seen at once.
2837
+ const ancestors = this.liveAncestors();
2838
+ for (let i = 0; i < ancestors.length; i++) {
2839
+ if (ancestors[i].EnableFieldLevelSecurity) {
2840
+ return true;
2841
+ }
2842
+ }
2843
+ return false;
2844
+ }
2845
+ /**
2846
+ * This entity's IS-A ancestors, nearest first, as {@link ParentEntityInfo} resolves them NOW
2847
+ * rather than the cached {@link ParentChain}. The denied walk ({@link addFieldsDeniedOnParent})
2848
+ * follows the live parent, so the gate and the memo key must too: read from a chain cached
2849
+ * before a metadata refresh replaced an ancestor, the gate could answer "no" while the
2850
+ * ancestor now in place has rules, and every enforcement point would skip them.
2851
+ *
2852
+ * The walk is memoized, valid while the field-security generation (which moves whenever any
2853
+ * `EntityInfo` is built, so a rebuilt ancestor is noticed), the provider this entity resolves
2854
+ * its parent through (its owning provider, else the global one), that provider's entity array
2855
+ * and this entity's `ParentID` are unchanged. Ancestors resolve their own parents through
2856
+ * their own providers; a chain whose entities came from one provider, the only way providers
2857
+ * build them, is covered by that provider's array. A walk cut
2858
+ * short because a `ParentID` did not resolve (asked before the provider held its entities) is
2859
+ * returned but not kept, so it cannot pin an incomplete answer.
2860
+ */
2861
+ liveAncestors() {
2862
+ if (!this.ParentID) {
2863
+ return NO_ANCESTORS;
2864
+ }
2865
+ const provider = MetadataProviderFor(this); // the provider ParentEntityInfo resolves through
2866
+ const entities = provider?.Entities;
2867
+ const memo = this._liveAncestorsMemo;
2868
+ if (memo !== null && memo.generation === _fieldSecurityGeneration && memo.provider === provider
2869
+ && memo.entities === entities && memo.parentID === this.ParentID) {
2870
+ return memo.ancestors;
2871
+ }
2872
+ const generation = _fieldSecurityGeneration;
2873
+ const { ancestors, complete } = this.walkLiveAncestors();
2874
+ if (complete) {
2875
+ this._liveAncestorsMemo = { generation, provider, entities, parentID: this.ParentID, ancestors };
2876
+ }
2877
+ return ancestors;
2878
+ }
2879
+ /** The uncached walk behind {@link liveAncestors}; stops at the root or at a cycle. */
2880
+ walkLiveAncestors() {
2881
+ const ancestors = [];
2882
+ const visited = new Set([this]);
2883
+ let current = this;
2884
+ let ancestor = this.ParentEntityInfo;
2885
+ while (ancestor && !visited.has(ancestor)) {
2886
+ visited.add(ancestor);
2887
+ ancestors.push(ancestor);
2888
+ current = ancestor;
2889
+ ancestor = ancestor.ParentID ? ancestor.ParentEntityInfo : null;
2890
+ }
2891
+ const complete = !current.ParentID || ancestor !== null;
2892
+ return { ancestors: Object.freeze(ancestors), complete };
2893
+ }
2538
2894
  /**
2539
2895
  * The set of field names this user may NOT READ on this entity — the per-request primitive
2540
2896
  * every field-security enforcement point is built on.
@@ -2545,8 +2901,9 @@ export class EntityInfo extends BaseInfo {
2545
2901
  * re-scans `user.UserRoles` and allocates. A Set lookup costs neither.
2546
2902
  *
2547
2903
  * 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
2904
+ * {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when field security
2905
+ * does not apply (see {@link FieldLevelSecurityApplies}) and when the user is denied nothing,
2906
+ * so callers can treat
2550
2907
  * `size === 0` as the single "nothing to do" condition.
2551
2908
  */
2552
2909
  GetDeniedReadFields(user) {
@@ -2566,10 +2923,10 @@ export class EntityInfo extends BaseInfo {
2566
2923
  * The caller still decides what to render in place of the value, which is the part that
2567
2924
  * differs per surface and should not be hidden inside a getter.
2568
2925
  *
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.
2926
+ * **Fails open** on a missing user, a missing field name, or an entity where field security
2927
+ * does not apply ({@link FieldLevelSecurityApplies}) — matching `BaseEntity`'s own gate and
2928
+ * `MjFormFieldComponent`. The server is the real boundary; a UI that blanked out fields
2929
+ * because no user had resolved yet would be worse than one that shows them.
2573
2930
  *
2574
2931
  * PERFORMANCE: this delegates to {@link GetDeniedReadFields}, which walks every field on the
2575
2932
  * entity. Fine for the handful of chrome reads it exists for; do NOT call it per row in a
@@ -2579,7 +2936,7 @@ export class EntityInfo extends BaseInfo {
2579
2936
  * @param user the acting user
2580
2937
  */
2581
2938
  IsFieldReadableByUser(fieldName, user) {
2582
- if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2939
+ if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
2583
2940
  return true;
2584
2941
  }
2585
2942
  return !this.GetDeniedReadFields(user).has(fieldName.trim().toLowerCase());
@@ -2591,7 +2948,7 @@ export class EntityInfo extends BaseInfo {
2591
2948
  * three conditions.
2592
2949
  */
2593
2950
  IsFieldUpdatableByUser(fieldName, user) {
2594
- if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2951
+ if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
2595
2952
  return true;
2596
2953
  }
2597
2954
  return !this.GetDeniedUpdateFields(user).has(fieldName.trim().toLowerCase());
@@ -2605,7 +2962,7 @@ export class EntityInfo extends BaseInfo {
2605
2962
  * the user typed, which is the case this exists to prevent.
2606
2963
  */
2607
2964
  IsFieldCreatableByUser(fieldName, user) {
2608
- if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2965
+ if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
2609
2966
  return true;
2610
2967
  }
2611
2968
  return !this.GetDeniedCreateFields(user).has(fieldName.trim().toLowerCase());
@@ -2631,51 +2988,67 @@ export class EntityInfo extends BaseInfo {
2631
2988
  GetDeniedCreateFields(user) {
2632
2989
  return this.getCachedDeniedFields(user, 'create', (p) => !p.CanCreate);
2633
2990
  }
2991
+ /**
2992
+ * Discards the memoized field-security answers: this entity's denied-field sets, those of
2993
+ * every IS-A descendant, and every other memo keyed on the field-security generation
2994
+ * ({@link CurrentFieldSecurityGeneration}), such as a user's denial profile across a catalog.
2995
+ * Invalidation is process-wide because a rule on one entity can change answers about others.
2996
+ *
2997
+ * A metadata refresh needs no call: it builds new `EntityInfo` objects, which start with
2998
+ * empty memos. Call this only after changing field rules IN PLACE on an existing
2999
+ * `EntityInfo`, for example editing an {@link EntityFieldPermissionInfo}'s access values.
3000
+ */
3001
+ InvalidateFieldSecurityCache() {
3002
+ advanceFieldSecurityGeneration();
3003
+ }
3004
+ /**
3005
+ * Memoized front for {@link getDeniedFields}, per (entity, user, verb).
3006
+ *
3007
+ * An entry is reused while four things are unchanged: the field-security generation (see
3008
+ * {@link CurrentFieldSecurityGeneration}; it moves on every rebuild, flag assignment and
3009
+ * {@link InvalidateFieldSecurityCache}), the IS-A ancestors as they resolve now, the field
3010
+ * array itself, and the role IDs the user holds. Counting fields or rules is not enough:
3011
+ * editing a rule from `Allow` to `Deny` changes neither count.
3012
+ */
2634
3013
  getCachedDeniedFields(user, verb, isDenied) {
2635
- if (!this.EnableFieldLevelSecurity) {
2636
- return new Set();
3014
+ if (!this.FieldLevelSecurityApplies) {
3015
+ return new Set(); // gate on inheritance too: an ancestor's rules still apply
2637
3016
  }
2638
3017
  if (!user || typeof user !== 'object') {
2639
- return this.getDeniedFields(user, isDenied);
3018
+ return this.getDeniedFields(user, verb, isDenied);
2640
3019
  }
2641
- let totalPerms = 0;
2642
- for (let i = 0; i < this._fields.length; i++) {
2643
- totalPerms += this._fields[i].FieldPermissions.length;
3020
+ const entry = this.deniedFieldsMemoEntryFor(user);
3021
+ const existing = entry[verb];
3022
+ if (existing !== undefined) {
3023
+ return existing;
2644
3024
  }
2645
- const rolesRef = user.UserRoles;
2646
- const rolesLen = rolesRef?.length ?? 0;
3025
+ const computed = this.getDeniedFields(user, verb, isDenied);
3026
+ entry[verb] = computed;
3027
+ return computed;
3028
+ }
3029
+ /** The current memo entry for `user`, replaced with an empty one when any part of its key moved. */
3030
+ deniedFieldsMemoEntryFor(user) {
3031
+ const generation = _fieldSecurityGeneration;
3032
+ const ancestors = this.liveAncestors();
3033
+ const rolesKey = userRolesKey(user);
2647
3034
  let byUser = _entityDeniedFieldsCache.get(this);
2648
3035
  if (!byUser) {
2649
3036
  byUser = new WeakMap();
2650
3037
  _entityDeniedFieldsCache.set(this, byUser);
2651
3038
  }
2652
3039
  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
- };
3040
+ if (!entry || entry.fieldsRef !== this._fields || entry.generation !== generation
3041
+ || !sameAncestors(entry.ancestors, ancestors) || entry.rolesKey !== rolesKey) {
3042
+ entry = { fieldsRef: this._fields, generation, ancestors, rolesKey };
2666
3043
  byUser.set(user, entry);
2667
3044
  }
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;
3045
+ return entry;
2675
3046
  }
2676
3047
  /**
2677
3048
  * Shared walk behind {@link GetDeniedReadFields} / {@link GetDeniedUpdateFields} /
2678
- * {@link GetDeniedCreateFields}, short-circuiting on {@link EnableFieldLevelSecurity}.
3049
+ * {@link GetDeniedCreateFields}. Walks this entity's own fields when its
3050
+ * {@link EnableFieldLevelSecurity} is on, then adds the fields an IS-A parent denies (see
3051
+ * {@link addFieldsDeniedOnParent}).
2679
3052
  *
2680
3053
  * Every field is aggregated, including those carrying no permission records — on an enabled
2681
3054
  * entity those are denied. Unrestrictable fields (primary keys, `__mj_` columns) come back
@@ -2698,18 +3071,54 @@ export class EntityInfo extends BaseInfo {
2698
3071
  * that collide on `CodeName` already collide on their generated property, which is not a
2699
3072
  * schema CodeGen can emit.
2700
3073
  */
2701
- getDeniedFields(user, isDenied) {
3074
+ getDeniedFields(user, verb, isDenied) {
2702
3075
  const denied = new Set();
2703
- if (!this.EnableFieldLevelSecurity) {
2704
- return denied;
3076
+ if (this.EnableFieldLevelSecurity) {
3077
+ for (const field of this._fields) {
3078
+ if (isDenied(field.GetUserFieldPermissions(user, true))) {
3079
+ this.addDeniedField(denied, field);
3080
+ }
3081
+ }
3082
+ }
3083
+ this.addFieldsDeniedOnParent(denied, user, verb, isDenied);
3084
+ return denied;
3085
+ }
3086
+ /**
3087
+ * IS-A inheritance for the denied sets: a field the immediate parent denies is denied on this
3088
+ * entity's mirror of it, whatever this entity's own flag says. The parent's set already
3089
+ * includes ITS parent's denials (this method runs there too), so one hop covers any depth.
3090
+ *
3091
+ * A mirror is a field of this entity whose `Name` is the `Name` of one of the parent's own
3092
+ * fields: an IS-A child's view exposes each parent column under the parent's column name, and
3093
+ * a child cannot declare a column of its own with that name. Matching goes through the
3094
+ * parent's field list rather than straight into the parent's denied set, because that set also
3095
+ * holds each denied field's `CodeName`: a parent field `Base Salary` puts `base_salary` in it,
3096
+ * and a child's OWN field named `Base_Salary` must not be denied for that.
3097
+ *
3098
+ * Additive only. When this entity ALSO has field security on, a mirror with no rows of its own
3099
+ * is already denied by the walk above (no rows on an enabled entity means denied); the parent's
3100
+ * rules cannot re-open it.
3101
+ */
3102
+ addFieldsDeniedOnParent(denied, user, verb, isDenied) {
3103
+ const parent = this.ParentEntityInfo;
3104
+ if (!parent?.FieldLevelSecurityApplies) {
3105
+ return;
3106
+ }
3107
+ const parentDenied = parent.getCachedDeniedFields(user, verb, isDenied);
3108
+ if (parentDenied.size === 0) {
3109
+ return;
2705
3110
  }
2706
3111
  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());
3112
+ const parentField = parent.FieldByName(field.Name);
3113
+ if (parentField && parentDenied.has(parentField.Name.trim().toLowerCase())) {
3114
+ this.addDeniedField(denied, field);
2710
3115
  }
2711
3116
  }
2712
- return denied;
3117
+ }
3118
+ /** Adds a field under BOTH key spaces — see {@link getDeniedFields} for why. */
3119
+ addDeniedField(denied, field) {
3120
+ denied.add(field.Name.trim().toLowerCase());
3121
+ denied.add(field.CodeName.trim().toLowerCase());
2713
3122
  }
2714
3123
  /**
2715
3124
  * O(1) case-insensitive field lookup by name. Use this instead of `Fields.find(f => f.Name === name)`
@@ -2970,6 +3379,53 @@ export class EntityInfo extends BaseInfo {
2970
3379
  get OrganicKeys() {
2971
3380
  return this._organicKeys;
2972
3381
  }
3382
+ /**
3383
+ * Gets all registered alternate base views for this entity, sorted by `Sequence`, `Name`, and `ID`.
3384
+ * Alternate base views maintain 100% column parity with {@link BaseView} while applying a
3385
+ * curated canonical filter or join.
3386
+ * @returns {EntityAlternateBaseViewInfo[]} Array of alternate base view metadata definitions
3387
+ */
3388
+ get AlternateBaseViews() {
3389
+ return this._alternateBaseViews;
3390
+ }
3391
+ /**
3392
+ * Finds a registered alternate base view on this entity by its semantic `Name`
3393
+ * (case-insensitive, whitespace-trimmed).
3394
+ *
3395
+ * @param name Semantic name of the alternate base view (for example, `'Active'`).
3396
+ * @returns The matching {@link EntityAlternateBaseViewInfo}, or `undefined` if not found.
3397
+ */
3398
+ GetAlternateBaseView(name) {
3399
+ const trimmed = name?.trim().toLowerCase();
3400
+ if (!trimmed) {
3401
+ return undefined;
3402
+ }
3403
+ return this._alternateBaseViews.find(v => v.Name?.trim().toLowerCase() === trimmed);
3404
+ }
3405
+ /**
3406
+ * Finds a registered alternate base view on this entity by its physical SQL `ViewName`
3407
+ * (and optional `schemaName`), matched case-insensitively and whitespace-trimmed.
3408
+ *
3409
+ * @param viewName Physical SQL view name (for example, `'vwActiveMembers'`).
3410
+ * @param schemaName Optional schema name; when supplied, must match `EffectiveSchemaName` case-insensitively.
3411
+ * @returns The matching {@link EntityAlternateBaseViewInfo}, or `undefined` if not found.
3412
+ */
3413
+ GetAlternateBaseViewByViewName(viewName, schemaName) {
3414
+ const trimmedView = viewName?.trim().toLowerCase();
3415
+ if (!trimmedView) {
3416
+ return undefined;
3417
+ }
3418
+ const trimmedSchema = schemaName?.trim().toLowerCase();
3419
+ return this._alternateBaseViews.find(v => {
3420
+ if (v.ViewName?.trim().toLowerCase() !== trimmedView) {
3421
+ return false;
3422
+ }
3423
+ if (trimmedSchema && trimmedSchema.length > 0) {
3424
+ return v.EffectiveSchemaName.trim().toLowerCase() === trimmedSchema;
3425
+ }
3426
+ return true;
3427
+ });
3428
+ }
2973
3429
  static { this.__createdAtFieldName = '__mj_CreatedAt'; }
2974
3430
  static { this.__updatedAtFieldName = '__mj_UpdatedAt'; }
2975
3431
  static { this.__deletedAtFieldName = '__mj_DeletedAt'; }
@@ -3160,22 +3616,33 @@ export class EntityInfo extends BaseInfo {
3160
3616
  * Walks the IS-A chain upward from this entity to the root, returning all parent entities.
3161
3617
  * Does NOT include this entity itself.
3162
3618
  * Example: For Webinars (IS-A Meetings IS-A Products), returns [Meetings, Products].
3163
- * Results are cached after first computation for performance.
3619
+ * Results are cached after first computation for performance — but only a COMPLETE chain:
3620
+ * one that ends at an entity with no parent, or at a cycle. A walk cut short because a
3621
+ * `ParentID` did not resolve (asked before the provider had its entities) is returned but not
3622
+ * kept, so it cannot pin an incomplete answer; field security reads this chain to decide
3623
+ * whether an ancestor's rules apply.
3164
3624
  */
3165
3625
  get ParentChain() {
3166
3626
  if (this._parentChainCache !== null)
3167
3627
  return this._parentChainCache;
3168
3628
  const chain = [];
3169
3629
  let current = this.ParentEntityInfo;
3630
+ let complete = !this.ParentID || current !== null;
3170
3631
  const visited = new Set(); // circular reference protection
3171
3632
  while (current) {
3172
3633
  if (visited.has(current.ID))
3173
3634
  break; // prevent infinite loop
3174
3635
  visited.add(current.ID);
3175
3636
  chain.push(current);
3176
- current = current.ParentEntityInfo;
3637
+ const next = current.ParentEntityInfo;
3638
+ if (current.ParentID && !next) {
3639
+ complete = false;
3640
+ }
3641
+ current = next;
3642
+ }
3643
+ if (complete) {
3644
+ this._parentChainCache = chain;
3177
3645
  }
3178
- this._parentChainCache = chain;
3179
3646
  return chain;
3180
3647
  }
3181
3648
  /**
@@ -3353,6 +3820,45 @@ export class EntityInfo extends BaseInfo {
3353
3820
  return null;
3354
3821
  }
3355
3822
  }
3823
+ /**
3824
+ * Checks whether the specified user has the requested permission (`Read`, `Create`, `Update`, or `Delete`)
3825
+ * on this entity. When `this.Permissions` is empty/uninitialized (e.g., in lightweight unit-test fixtures),
3826
+ * returns `true` so unconfigured fixtures are not blocked.
3827
+ */
3828
+ CheckPermissions(user, type, throwError = false) {
3829
+ if (!user || !this.Permissions || this.Permissions.length === 0) {
3830
+ return true;
3831
+ }
3832
+ const perms = this.GetUserPermisions(user);
3833
+ if (!perms) {
3834
+ if (throwError) {
3835
+ throw new Error(`Permission check failed for user on entity ${this.Name}`);
3836
+ }
3837
+ return false;
3838
+ }
3839
+ let allowed = false;
3840
+ switch (type) {
3841
+ case EntityPermissionType.Read:
3842
+ allowed = perms.CanRead;
3843
+ break;
3844
+ case EntityPermissionType.Create:
3845
+ allowed = perms.CanCreate;
3846
+ break;
3847
+ case EntityPermissionType.Update:
3848
+ allowed = perms.CanUpdate;
3849
+ break;
3850
+ case EntityPermissionType.Delete:
3851
+ allowed = perms.CanDelete;
3852
+ break;
3853
+ default:
3854
+ allowed = false;
3855
+ break;
3856
+ }
3857
+ if (!allowed && throwError) {
3858
+ throw new Error(`User does not have ${type} permission on ${this.Name}`);
3859
+ }
3860
+ return allowed;
3861
+ }
3356
3862
  /**
3357
3863
  * Determines if a given user, for a given permission type, is exempt from RowLevelSecurity or not.
3358
3864
  *
@@ -3480,18 +3986,16 @@ export class EntityInfo extends BaseInfo {
3480
3986
  return '';
3481
3987
  }
3482
3988
  const userRLS = this.GetUserRowLevelSecurityInfo(user, type);
3483
- if (userRLS && userRLS.length > 0) {
3484
- // userRLS has all of the objects that apply to this user. The user is NOT exempt from RLS, so we need to OR together all of the RLS object filters
3485
- let sRLSSQL = '';
3486
- userRLS.forEach((rls) => {
3487
- if (sRLSSQL.length > 0)
3488
- sRLSSQL += ' OR ';
3489
- sRLSSQL += `(${rls.MarkupFilterText(user)})`;
3490
- });
3491
- return sRLSSQL.length > 0 ? `${returnPrefix && returnPrefix.length > 0 ? returnPrefix + ' ' : ''}${sRLSSQL}` : '';
3492
- }
3493
- else
3989
+ if (!userRLS || userRLS.length === 0) {
3494
3990
  return '';
3991
+ }
3992
+ // The user is NOT exempt, so every filter that applies is OR'd together. Each filter is
3993
+ // parenthesized, and a group of two or more is parenthesized as a whole, so a predicate the
3994
+ // caller ANDs beside the clause (`WHERE ID = @p0 AND <clause>`) applies to every filter rather
3995
+ // than binding to the first one only. A single filter keeps its one pair of parentheses.
3996
+ const terms = userRLS.map((rls) => `(${rls.MarkupFilterText(user)})`);
3997
+ const sRLSSQL = terms.length > 1 ? `(${terms.join(' OR ')})` : terms[0];
3998
+ return `${returnPrefix && returnPrefix.length > 0 ? returnPrefix + ' ' : ''}${sRLSSQL}`;
3495
3999
  }
3496
4000
  /**
3497
4001
  * The EFFECTIVE row-filter clause for a user + permission type: role RLS (subject to the
@@ -3848,6 +4352,7 @@ export class EntityInfo extends BaseInfo {
3848
4352
  'EntitySettings', '_Settings', 'Settings',
3849
4353
  'EntityRelationships', '_RelatedEntities', 'RelatedEntities',
3850
4354
  'EntityOrganicKeys', '_OrganicKeys', 'OrganicKeys',
4355
+ 'EntityAlternateBaseViews', '_AlternateBaseViews', 'AlternateBaseViews',
3851
4356
  ]); }
3852
4357
  /**
3853
4358
  * @param initData - the raw entity row (or another EntityInfo being re-instantiated)
@@ -4005,19 +4510,7 @@ export class EntityInfo extends BaseInfo {
4005
4510
  * client-side IndexedDB cache. Zero overhead on hot save/query paths.
4006
4511
  */
4007
4512
  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;
4513
+ this._enableFieldLevelSecurity = false;
4021
4514
  /**
4022
4515
  * Whether this entity is available through the GraphQL API
4023
4516
  */
@@ -4309,6 +4802,7 @@ export class EntityInfo extends BaseInfo {
4309
4802
  this.ParentBaseView = null;
4310
4803
  this._fieldCategories = null;
4311
4804
  this._organicKeys = [];
4805
+ this._alternateBaseViews = [];
4312
4806
  this.HasIdField = false;
4313
4807
  this.VirtualCount = 0;
4314
4808
  this.ManyToManyCount = 0;
@@ -4329,12 +4823,17 @@ export class EntityInfo extends BaseInfo {
4329
4823
  this._nameFieldCache = undefined;
4330
4824
  /** Memoized computed plural, keyed by the display name it was derived from (see `DisplayNamePlural`). */
4331
4825
  this._displayNamePluralCache = undefined;
4826
+ /** See {@link liveAncestors}. A `_` field with no matching getter, so `toJSON()` leaves it out. */
4827
+ this._liveAncestorsMemo = null;
4332
4828
  this._hasInactiveFields = undefined;
4333
4829
  this._hasSearchFields = undefined;
4334
4830
  // Cache for ParentChain to avoid repeated walks
4335
4831
  this._parentChainCache = null;
4336
4832
  // Cache for ParentEntityFieldNames
4337
4833
  this._parentEntityFieldNamesCache = null;
4834
+ // A new entity can replace one that memoized field-security answers point at (a metadata
4835
+ // refresh, an IS-A ancestor rebuilt), so building one moves the generation.
4836
+ advanceFieldSecurityGeneration();
4338
4837
  this.SetOwningProvider(md);
4339
4838
  if (initData) {
4340
4839
  this.copyInitData(initData, EntityInfo.CHILD_INIT_KEYS);
@@ -4424,6 +4923,22 @@ export class EntityInfo extends BaseInfo {
4424
4923
  this._organicKeys.push(new EntityOrganicKeyInfo(item));
4425
4924
  }
4426
4925
  }
4926
+ // copy the Alternate Base Views (sorted by Sequence, Name, ID, then linked via ParentID)
4927
+ this._alternateBaseViews = [];
4928
+ const abv = initData.EntityAlternateBaseViews || initData._AlternateBaseViews || initData.AlternateBaseViews;
4929
+ if (abv && Array.isArray(abv)) {
4930
+ for (const item of abv) {
4931
+ const viewInfo = new EntityAlternateBaseViewInfo(item, this.SchemaName);
4932
+ viewInfo.SetOwningProvider(md);
4933
+ this._alternateBaseViews.push(viewInfo);
4934
+ }
4935
+ this._alternateBaseViews.sort((a, b) => {
4936
+ const aSeq = a.Sequence !== null && a.Sequence !== undefined ? a.Sequence : 0;
4937
+ const bSeq = b.Sequence !== null && b.Sequence !== undefined ? b.Sequence : 0;
4938
+ return (aSeq - bSeq) || ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID);
4939
+ });
4940
+ EntityAlternateBaseViewInfo.LinkHierarchy(this._alternateBaseViews, this.SchemaName);
4941
+ }
4427
4942
  this.prepareSpecialFields();
4428
4943
  }
4429
4944
  }