@memberjunction/core 6.1.0-edge.2 → 6.1.0-edge.3

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 (90) hide show
  1. package/LICENSE +180 -4
  2. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +120 -0
  3. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -0
  4. package/dist/generic/JSONType-interfaces/IEntityConfiguration.js +6 -0
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.js.map +1 -0
  6. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +31 -0
  7. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -0
  8. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js +6 -0
  9. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js.map +1 -0
  10. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +75 -0
  11. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -0
  12. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js +6 -0
  13. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js.map +1 -0
  14. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts +19 -0
  15. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts.map +1 -0
  16. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js +6 -0
  17. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js.map +1 -0
  18. package/dist/generic/authTypes.d.ts +69 -0
  19. package/dist/generic/authTypes.d.ts.map +1 -1
  20. package/dist/generic/baseEngine.d.ts +14 -0
  21. package/dist/generic/baseEngine.d.ts.map +1 -1
  22. package/dist/generic/baseEngine.js +53 -13
  23. package/dist/generic/baseEngine.js.map +1 -1
  24. package/dist/generic/baseEntity.d.ts +113 -20
  25. package/dist/generic/baseEntity.d.ts.map +1 -1
  26. package/dist/generic/baseEntity.js +351 -49
  27. package/dist/generic/baseEntity.js.map +1 -1
  28. package/dist/generic/baseInfo.d.ts.map +1 -1
  29. package/dist/generic/baseInfo.js +16 -4
  30. package/dist/generic/baseInfo.js.map +1 -1
  31. package/dist/generic/compositeKey.d.ts +30 -0
  32. package/dist/generic/compositeKey.d.ts.map +1 -1
  33. package/dist/generic/compositeKey.js +97 -0
  34. package/dist/generic/compositeKey.js.map +1 -1
  35. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  36. package/dist/generic/databaseProviderBase.js +9 -5
  37. package/dist/generic/databaseProviderBase.js.map +1 -1
  38. package/dist/generic/embeddedRecord.d.ts +123 -0
  39. package/dist/generic/embeddedRecord.d.ts.map +1 -0
  40. package/dist/generic/embeddedRecord.js +356 -0
  41. package/dist/generic/embeddedRecord.js.map +1 -0
  42. package/dist/generic/entityCompanion.d.ts +20 -2
  43. package/dist/generic/entityCompanion.d.ts.map +1 -1
  44. package/dist/generic/entityCompanion.js +17 -1
  45. package/dist/generic/entityCompanion.js.map +1 -1
  46. package/dist/generic/entityConfiguration.d.ts +131 -0
  47. package/dist/generic/entityConfiguration.d.ts.map +1 -0
  48. package/dist/generic/entityConfiguration.js +346 -0
  49. package/dist/generic/entityConfiguration.js.map +1 -0
  50. package/dist/generic/entityInfo.d.ts +274 -4
  51. package/dist/generic/entityInfo.d.ts.map +1 -1
  52. package/dist/generic/entityInfo.js +502 -14
  53. package/dist/generic/entityInfo.js.map +1 -1
  54. package/dist/generic/entitySavePlan.d.ts +40 -11
  55. package/dist/generic/entitySavePlan.d.ts.map +1 -1
  56. package/dist/generic/entitySavePlan.js +70 -2
  57. package/dist/generic/entitySavePlan.js.map +1 -1
  58. package/dist/generic/interfaces.d.ts +6 -24
  59. package/dist/generic/interfaces.d.ts.map +1 -1
  60. package/dist/generic/interfaces.js +6 -24
  61. package/dist/generic/interfaces.js.map +1 -1
  62. package/dist/generic/localCacheManager.d.ts.map +1 -1
  63. package/dist/generic/localCacheManager.js +10 -0
  64. package/dist/generic/localCacheManager.js.map +1 -1
  65. package/dist/generic/providerBase.d.ts +19 -0
  66. package/dist/generic/providerBase.d.ts.map +1 -1
  67. package/dist/generic/providerBase.js +71 -6
  68. package/dist/generic/providerBase.js.map +1 -1
  69. package/dist/generic/relatedRecordCollection.d.ts +25 -3
  70. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  71. package/dist/generic/relatedRecordCollection.js +53 -7
  72. package/dist/generic/relatedRecordCollection.js.map +1 -1
  73. package/dist/generic/runQuery.d.ts +14 -0
  74. package/dist/generic/runQuery.d.ts.map +1 -1
  75. package/dist/generic/runQuery.js.map +1 -1
  76. package/dist/generic/saveEntityGraphOperation.js +1 -1
  77. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  78. package/dist/generic/securityInfo.d.ts.map +1 -1
  79. package/dist/generic/securityInfo.js +15 -5
  80. package/dist/generic/securityInfo.js.map +1 -1
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +3 -0
  84. package/dist/index.js.map +1 -1
  85. package/dist/views/runView.d.ts +23 -0
  86. package/dist/views/runView.d.ts.map +1 -1
  87. package/dist/views/runView.js +20 -0
  88. package/dist/views/runView.js.map +1 -1
  89. package/package.json +5 -4
  90. package/readme.md +20 -1
@@ -4,6 +4,7 @@ import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, FormatValue, Code
4
4
  import { IsFixedWidthStringSQLType } from "@memberjunction/sql-dialect";
5
5
  import { LogError } from "./logging.js";
6
6
  import { WarningManager, SafeJSONParse, UUIDsEqual } from "@memberjunction/global";
7
+ import { ReadRelationshipJoinFields, } from "./entityConfiguration.js";
7
8
  /**
8
9
  * The possible status values for a record change
9
10
  */
@@ -47,6 +48,41 @@ export class RecordChange extends BaseInfo {
47
48
  * Defines foreign key relationships and how they should be represented in the user interface.
48
49
  */
49
50
  export class EntityRelationshipInfo extends BaseInfo {
51
+ /**
52
+ * Optional JSON configuration bag (shape = {@link IEntityRelationshipConfiguration}).
53
+ * Nested `UI.inclusion` is Primary, More, or None (omit = Auto ranker).
54
+ * `UI.FormRole` is an accepted alias (`Detail` = More). Distinct from
55
+ * RelatedRecordCollection, DisplayComponentConfiguration, and AdditionalFieldsToInclude.
56
+ * Parsed lazily on first access and cached using {@link SafeJSONParse}.
57
+ *
58
+ * @see packages/MJCore/src/generic/entityConfiguration.ts
59
+ */
60
+ get Configuration() {
61
+ if (this._configurationObject === undefined) {
62
+ this._configurationObject = this._configuration ? SafeJSONParse(this._configuration, false) : null;
63
+ }
64
+ return this._configurationObject;
65
+ }
66
+ set Configuration(value) {
67
+ if (typeof value === 'string') {
68
+ this._configuration = value;
69
+ this._configurationObject = undefined;
70
+ }
71
+ else if (value && typeof value === 'object') {
72
+ this._configurationObject = value;
73
+ this._configuration = JSON.stringify(value);
74
+ }
75
+ else {
76
+ this._configuration = null;
77
+ this._configurationObject = null;
78
+ }
79
+ }
80
+ /**
81
+ * Parsed {@link Configuration}. Null when the column is empty or not valid JSON.
82
+ */
83
+ get ConfigurationObject() {
84
+ return this.Configuration;
85
+ }
50
86
  constructor(initData) {
51
87
  super();
52
88
  this.ID = null;
@@ -79,6 +115,14 @@ export class EntityRelationshipInfo extends BaseInfo {
79
115
  * * Description: Indicates whether this relationship should be automatically updated by CodeGen. When set to 0, the record will not be modified by CodeGen. Defaults to true.
80
116
  */
81
117
  this.AutoUpdateFromSchema = true;
118
+ /**
119
+ * Comma-delimited list of extra related-entity fields to project in base views.
120
+ */
121
+ this.AdditionalFieldsToInclude = null;
122
+ /**
123
+ * Whether CodeGen automatically updates AdditionalFieldsToInclude from schema metadata.
124
+ */
125
+ this.AutoUpdateAdditionalFieldsToInclude = true;
82
126
  /**
83
127
  * * Field Name: RelatedRecordCollection
84
128
  * * SQL Data Type: nvarchar(MAX), nullable
@@ -100,6 +144,11 @@ export class EntityRelationshipInfo extends BaseInfo {
100
144
  * @see guides/TRANSACTIONS_AND_BATCHING_GUIDE.md
101
145
  */
102
146
  this.RelatedRecordCollection = null;
147
+ /**
148
+ * Raw string representation of Configuration from metadata.
149
+ */
150
+ this._configuration = null;
151
+ this._configurationObject = undefined;
103
152
  // virtual fields - returned by the database VIEW
104
153
  this.Entity = null;
105
154
  this.EntityBaseTable = null;
@@ -379,6 +428,49 @@ export const GeneratedFormSectionType = {
379
428
  * Includes data types, relationships, defaults, and UI display preferences.
380
429
  */
381
430
  export class EntityFieldInfo extends BaseInfo {
431
+ /**
432
+ * Optional JSON configuration bag (shape = {@link IEntityFieldConfiguration}).
433
+ * Defines field-level configurations such as Hierarchy options (IsHierarchy, MaxDepth).
434
+ * Parsed lazily on first access and cached using {@link SafeJSONParse}.
435
+ */
436
+ get Configuration() {
437
+ if (this._configurationObject === undefined) {
438
+ this._configurationObject = this._configuration ? SafeJSONParse(this._configuration, false) : null;
439
+ }
440
+ return this._configurationObject;
441
+ }
442
+ set Configuration(value) {
443
+ if (typeof value === 'string') {
444
+ this._configuration = value;
445
+ this._configurationObject = undefined;
446
+ }
447
+ else if (value && typeof value === 'object') {
448
+ this._configurationObject = value;
449
+ this._configuration = JSON.stringify(value);
450
+ }
451
+ else {
452
+ this._configuration = null;
453
+ this._configurationObject = null;
454
+ }
455
+ }
456
+ /**
457
+ * Parsed {@link Configuration}. Null when the column is empty or not valid JSON.
458
+ */
459
+ get ConfigurationObject() {
460
+ return this.Configuration;
461
+ }
462
+ /**
463
+ * Returns true if this field is explicitly configured as an intentional recursive tree hierarchy.
464
+ */
465
+ get IsHierarchy() {
466
+ return this.ConfigurationObject?.Hierarchy?.IsHierarchy === true;
467
+ }
468
+ /**
469
+ * Maximum recursion depth configured for this hierarchy field (defaults to 100).
470
+ */
471
+ get HierarchyMaxDepth() {
472
+ return this.ConfigurationObject?.Hierarchy?.MaxDepth ?? 100;
473
+ }
382
474
  /**
383
475
  * JSON configuration for additional fields to join from the related entity.
384
476
  * Parsed from the RelatedEntityJoinFields column. Uses lazy initialization and caching
@@ -432,6 +524,233 @@ export class EntityFieldInfo extends BaseInfo {
432
524
  }
433
525
  }
434
526
  }
527
+ /**
528
+ * Upper bound on how many legal values a validation message enumerates before it truncates.
529
+ * A long list would otherwise produce an error message no user can read.
530
+ */
531
+ static { this.MaxValueListValuesInErrorMessage = 25; }
532
+ /**
533
+ * Normalizes a value for comparison against a value list: stringified, trimmed, lower-cased.
534
+ *
535
+ * Each part earns its place, and the reasons are NOT equally strong — stated precisely, because
536
+ * a future maintainer will use this to decide whether to tighten the comparison:
537
+ * * **Lower-casing is load-bearing on MJ core itself.** Not merely defensive: the default-value
538
+ * path puts a field's SQL default into a new record (`EntityField`'s constructor assigns
539
+ * `DefaultValue` when no value is supplied), and two MJ core fields have a default that
540
+ * matches their value list by CASE ALONE — `MJ: Entity AI Actions`.TriggerEvent defaults to
541
+ * `'After Save'` against a list of `before save | after save`, and its OutputType defaults to
542
+ * `'FIeld'` against `entity | field`. Under a case-sensitive comparison, creating either
543
+ * record at its database default would fail validation. Separately, SQL Server's default
544
+ * collation is case-insensitive, so `Status = 'active'` is accepted by
545
+ * `CHECK (Status IN ('Active', ...))` and refusing it here would turn a save that succeeds
546
+ * today into a failure. (PostgreSQL IS case-sensitive, so on PG a case variant is still
547
+ * refused — by its CHECK, not by this rung.)
548
+ * * **Stringifying is required.** `EntityFieldValue.Value` is always a string in metadata while
549
+ * the field's runtime value may be a number, so a strict `===` would reject every legal value
550
+ * on a numeric list. It is not lossless: `String(1.0)` is `'1'`, so a metadata value written
551
+ * as `'1.0'` would fail closed. No numeric value lists exist today (CodeGen cannot produce
552
+ * one — see the note in ValueIsPermittedByValueList), so this is recorded rather than solved.
553
+ * * **Trimming is cheap insurance, NOT the load-bearing rule it was first documented as.** An
554
+ * earlier version of this comment claimed an untrimmed comparison would reject 9,301 existing
555
+ * rows in fixed-width `nchar` columns (`MJ: Action Params`.Type, `MJ: Record Changes`.Status
556
+ * and others). That measurement was taken over RAW SQL ROWS and does not describe this code
557
+ * path: `EntityField`'s value setter already strips trailing padding on fixed-width columns
558
+ * (see `FixedWidthColumn`), and hydration assigns through that setter, so the padding is gone
559
+ * before `Validate()` ever reads the value. Trimming is kept because it still covers LEADING
560
+ * whitespace, stray spaces on the metadata side, and any caller that assigns a padded value
561
+ * directly — none of which the setter handles.
562
+ */
563
+ static NormalizeValueListValue(value) {
564
+ return String(value).trim().toLowerCase();
565
+ }
566
+ /**
567
+ * Whether `value` is permitted by this field's exhaustive value list (MJ issue #3969).
568
+ *
569
+ * A field whose `ValueListType` is `List` carries an exhaustive set of legal values in
570
+ * `__mj.EntityFieldValue`, and for an `IN (...)` CHECK constraint that list is the ONLY runtime
571
+ * representation CodeGen produces — `ParseCheckConstraints` emits the value list rather than a
572
+ * generated `Validate()` method, since the list is also what the UI needs to render a dropdown.
573
+ * So this is the only place such a constraint can be caught before the database refuses it as a
574
+ * raw violation attributed to no field.
575
+ *
576
+ * The normalized set is built ONCE per field and reused, because it derives from metadata that
577
+ * is immutable after load and is shared by every `EntityField` instance of this field — at
578
+ * import scale (thousands to millions of rows) rebuilding it per record is pure waste.
579
+ *
580
+ * Four boundaries keep the rule safe to apply everywhere:
581
+ * * `ListOrUserEntry` is never checked — that mode exists precisely to permit values outside
582
+ * the list, so validating it would break every field that opted into free text.
583
+ * * A `List` field with no `EntityFieldValue` rows permits everything. Strictly it describes
584
+ * a field where nothing is legal, which should never exist; it means the metadata is
585
+ * broken, not that every value is wrong, so this logs loudly (once per EntityFieldInfo
586
+ * instance, which means it re-arms after a metadata refresh rather than being once ever)
587
+ * and permits rather than failing every save on the field.
588
+ * * Null/undefined is the nullability check's job, so one mistake never produces two errors.
589
+ * An EMPTY or whitespace-only string is NOT absence: SQL Server pads on comparison, so
590
+ * `''` and `' '` are the same value to a CHECK constraint and it refuses both. Skipping
591
+ * them would leave a hole exactly where a blanked-out field lands.
592
+ * * Only string and number values are checked, and the gate fails OPEN — an unsupported type
593
+ * skips validation rather than manufacturing a failure. Nothing in the schema restricts
594
+ * which columns may carry a value list (`CK_EntityField_ValueListType_New` constrains the
595
+ * mode, not the column type), but in practice every one is a string column: measured on a
596
+ * current 6.x instance, 455 nvarchar + 7 nchar and nothing else, which follows from
597
+ * CodeGen's constraint parser only ever extracting quoted literals. `number` is admitted
598
+ * because the generated union type anticipates a non-quoted list via `NeedsQuotes`.
599
+ * Booleans and Dates are excluded deliberately: a bit column carrying a `'1'`/`'0'` list
600
+ * would see `String(true) === 'true'` and reject every legal value, and a Date has no sane
601
+ * string form to compare — so guessing there would break saves rather than guard them.
602
+ *
603
+ * TWO PRODUCERS, ONE OF WHICH HAS NO DATABASE FLOOR. A CHECK-derived list is safe by
604
+ * construction: the database refuses anything this rung refuses, so validating can only move a
605
+ * failure earlier. The other producer is `applyValueListConfig` in CodeGen, which applies
606
+ * DBAutoDoc's LLM enum detection from `additionalSchemaInfo` — those fields have NO CHECK
607
+ * constraint, so for them this rung converts a sampled, confidence-scored guess into a hard save
608
+ * refusal for any value the model did not see. It is opt-in (the config must exist) and arguably
609
+ * the intended reading of `List` as a closed set, with `ListOrUserEntry` available when unsure —
610
+ * but it means "MJ never refuses what the database would accept" holds for the first producer
611
+ * only.
612
+ *
613
+ * @param value the field's current runtime value
614
+ * @returns true when the value is permitted, INCLUDING when the rule does not apply
615
+ */
616
+ ValueIsPermittedByValueList(value) {
617
+ if (this.ValueListTypeEnum !== EntityFieldValueListType.List) {
618
+ return true;
619
+ }
620
+ if (this._normalizedValueListValues === undefined) {
621
+ const values = this.EntityFieldValues ?? [];
622
+ this._normalizedValueListValues = new Set(values.map(v => EntityFieldInfo.NormalizeValueListValue(v.Value)));
623
+ }
624
+ // The broken-metadata report comes FIRST, before the null and type gates, so that it is
625
+ // value-independent: a `List` field with no values whose column happens to hold null (or a
626
+ // boolean) would otherwise never report at all, making "loud" mean "loud if someone happens
627
+ // to set a string". The cost is building one memoized set on a field that would build it
628
+ // anyway.
629
+ if (this._normalizedValueListValues.size === 0) {
630
+ if (!this._loggedEmptyValueList) {
631
+ this._loggedEmptyValueList = true; // latched: one report per field, not per row
632
+ LogError(`Entity field ${this.Entity}.${this.Name} has ValueListType='List' but no EntityFieldValue rows. ` +
633
+ `That describes a field where no value is legal, which is broken metadata rather than a rule, ` +
634
+ `so value-list validation is being SKIPPED for this field. Re-run CodeGen for the entity, or ` +
635
+ `set ValueListType='None' if the field is not meant to be constrained.`);
636
+ }
637
+ return true;
638
+ }
639
+ if (value === null || value === undefined) {
640
+ return true; // an unset nullable field or a new record — the nullability check's business
641
+ }
642
+ // Dates are compared on the calendar date rather than the string form — see
643
+ // dateValueIsPermittedByValueList. A `date` column CAN carry a value list: SQL Server stores
644
+ // `CHECK (D IN ('2026-01-01','2026-07-01'))` as quoted literals, which is exactly the shape
645
+ // CodeGen's parser captures, so this is a reachable case rather than a hypothetical one.
646
+ if (value instanceof Date) {
647
+ return this.dateValueIsPermittedByValueList(value);
648
+ }
649
+ if (typeof value !== 'string' && typeof value !== 'number') {
650
+ // The rule cannot be applied to this type at all, and that is a mismatch rather than a
651
+ // state to absorb: either the field should not declare a value list (one on a bit column
652
+ // — which CodeGen never produces, since SQL Server renders `IN (0,1)` as unquoted
653
+ // `([B]=(1) OR [B]=(0))`, the same reason no NUMERIC list exists either; see MJ #3978)
654
+ // or a caller assigned the wrong type. Skipping it silently would
655
+ // leave the caller believing a guard is on when it is not, which is the exact failure
656
+ // mode this rung was added to fix — so it is reported, once per field.
657
+ this.reportUnsupportedValueListValue(`a ${typeof value} value (SQL type ${this.SQLFullType})`, 'value-list validation compares strings, numbers and dates only — comparing anything ' +
658
+ 'else would reject legal values rather than guard them');
659
+ return true;
660
+ }
661
+ return this._normalizedValueListValues.has(EntityFieldInfo.NormalizeValueListValue(value));
662
+ }
663
+ /**
664
+ * Value-list membership for a `Date` runtime value, compared on the CALENDAR DATE rather than
665
+ * the instant.
666
+ *
667
+ * Only `date` columns are compared. A column carrying a time component is deliberately skipped:
668
+ * the metadata value has no timezone, so deciding whether `'2026-01-01T00:00:00'` is the same
669
+ * instant as the value read back would mean guessing the database's interpretation, and guessing
670
+ * wrong rejects a legal value.
671
+ *
672
+ * For a `date` column the comparison accepts EITHER the value's UTC calendar date or its LOCAL
673
+ * one. That asymmetry is deliberate: a value read back from SQL Server arrives as UTC midnight,
674
+ * while application code that builds a date with `new Date(2026, 6, 1)` produces LOCAL midnight —
675
+ * whose UTC calendar date is the previous day west of Greenwich. Insisting on one representation
676
+ * would refuse legal values for half the world, so a date is in the list if either reading of it
677
+ * is. The cost is that an adjacent day can slip through when both days are in the list, which is
678
+ * a far better trade than a false failure.
679
+ */
680
+ dateValueIsPermittedByValueList(value) {
681
+ if (this._valueListDateKeys === undefined) {
682
+ this._valueListDateKeys = this.buildValueListDateKeys();
683
+ }
684
+ if (this._valueListDateKeys === null) {
685
+ this.reportUnsupportedValueListValue(`a Date value (SQL type ${this.SQLFullType})`, 'only a `date` column whose value list is entirely yyyy-mm-dd literals can be ' +
686
+ 'compared — anything else would require guessing how the database interprets a ' +
687
+ 'time-zone-less literal');
688
+ return true;
689
+ }
690
+ if (Number.isNaN(value.getTime())) {
691
+ return true; // an Invalid Date is not an out-of-list value; leave it to the date check
692
+ }
693
+ return this._valueListDateKeys.has(value.toISOString().slice(0, 10)) ||
694
+ this._valueListDateKeys.has(EntityFieldInfo.LocalCalendarDate(value));
695
+ }
696
+ /**
697
+ * Builds the set of yyyy-mm-dd keys for a `date` field's value list, or null when the field is
698
+ * not a plain `date` column or any of its values is not a yyyy-mm-dd literal. Reads the already
699
+ * normalized values, so it costs no extra pass over the metadata (lower-casing cannot affect a
700
+ * numeric date literal).
701
+ */
702
+ buildValueListDateKeys() {
703
+ if ((this.Type ?? '').trim().toLowerCase() !== 'date') {
704
+ return null;
705
+ }
706
+ const keys = new Set();
707
+ for (const normalized of this._normalizedValueListValues ?? []) {
708
+ const match = /^(\d{4}-\d{2}-\d{2})$/.exec(normalized);
709
+ if (!match) {
710
+ return null; // not a date list — refuse to guess rather than reject legal values
711
+ }
712
+ keys.add(match[1]);
713
+ }
714
+ return keys.size > 0 ? keys : null;
715
+ }
716
+ /** The Date's LOCAL calendar date as yyyy-mm-dd (its UTC one is `toISOString().slice(0, 10)`). */
717
+ static LocalCalendarDate(value) {
718
+ const pad = (n) => String(n).padStart(2, '0');
719
+ return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
720
+ }
721
+ /**
722
+ * Reports, once per field, that a value could not be checked against the field's value list.
723
+ * Latched because the metadata is shared by every record: a bulk load would otherwise emit the
724
+ * same line per row, which is the noise the memoization elsewhere in this class exists to avoid.
725
+ */
726
+ reportUnsupportedValueListValue(received, reason) {
727
+ if (this._loggedUnsupportedValueListType) {
728
+ return;
729
+ }
730
+ this._loggedUnsupportedValueListType = true;
731
+ LogError(`Entity field ${this.Entity}.${this.Name} has ValueListType='List' but was given ${received}: ` +
732
+ `${reason}, so value-list validation is being SKIPPED for this field. Either the field's ` +
733
+ `metadata should not declare a value list, or the caller is assigning the wrong type.`);
734
+ }
735
+ /**
736
+ * This field's legal values formatted for a validation message, truncated past
737
+ * {@link MaxValueListValuesInErrorMessage}. Memoized — a bad bulk load fails on the same field
738
+ * repeatedly, and the string never changes.
739
+ */
740
+ get ValueListValuesForDisplay() {
741
+ if (this._valueListDisplayCache !== undefined) {
742
+ return this._valueListDisplayCache;
743
+ }
744
+ // De-duplicated: EntityFieldValue rows are not unique in practice (`MJ: Action Params`.ValueType
745
+ // carries repeats today), and listing the same value twice — or double-counting it in the
746
+ // "(N total)" tail — makes the message look like a bug in the message.
747
+ const values = [...new Set((this.EntityFieldValues ?? []).map(v => v.Value))];
748
+ const max = EntityFieldInfo.MaxValueListValuesInErrorMessage;
749
+ this._valueListDisplayCache = values.length > max ?
750
+ `${values.slice(0, max).join(', ')}, ... (${values.length} total)` :
751
+ values.join(', ');
752
+ return this._valueListDisplayCache;
753
+ }
435
754
  get GeneratedFormSectionType() {
436
755
  return GeneratedFormSectionType[this.GeneratedFormSection];
437
756
  }
@@ -753,6 +1072,18 @@ export class EntityFieldInfo extends BaseInfo {
753
1072
  */
754
1073
  this.DisplayName = null;
755
1074
  this.Description = null;
1075
+ /**
1076
+ * Whether CodeGen automatically updates Description from the underlying schema object.
1077
+ */
1078
+ this.AutoUpdateDescription = true;
1079
+ /**
1080
+ * Whether CodeGen automatically updates UserSearchPredicateAPI from schema heuristics.
1081
+ */
1082
+ this.AutoUpdateUserSearchPredicate = true;
1083
+ /**
1084
+ * Whether CodeGen automatically updates FullTextSearchEnabled from schema indexes.
1085
+ */
1086
+ this.AutoUpdateFullTextSearch = true;
756
1087
  /**
757
1088
  * If true, the field is the primary key for the entity. There must be one primary key field per entity.
758
1089
  */
@@ -824,6 +1155,22 @@ export class EntityFieldInfo extends BaseInfo {
824
1155
  * Parsed from the RelatedEntityJoinFields column.
825
1156
  */
826
1157
  this.RelatedEntityJoinFields = null;
1158
+ /**
1159
+ * Optional JSON policy object declaring this foreign-key field as a first-class
1160
+ * **embedded record** — a 1:1 peer that loads, validates and persists as one
1161
+ * unit with its owner. Shape is `IEmbeddedRecordConfig` (`OnClear`, `LoadNested`).
1162
+ *
1163
+ * `RelatedEntityID` and this field's `Name` are the join; they are deliberately
1164
+ * not repeated inside the JSON. `AllowsNull` on this same field decides whether
1165
+ * `GetEntityObject` provisions the object (required FK) or the caller uses
1166
+ * `{FieldName}_EnsureObject()` (nullable FK).
1167
+ *
1168
+ * `null` (the default, and every pre-feature row) means the field is an ordinary
1169
+ * FK: nothing is generated and nothing is constructed at `GetEntityObject` time.
1170
+ *
1171
+ * @see packages/MJCore/docs/embedded-records.md
1172
+ */
1173
+ this.EmbeddedRecord = null;
827
1174
  /**
828
1175
  * The name of the TypeScript interface/type for this JSON field.
829
1176
  * When set, CodeGen will emit a strongly-typed getter/setter using this type
@@ -841,6 +1188,11 @@ export class EntityFieldInfo extends BaseInfo {
841
1188
  * Can include imports, multiple types, or any valid TypeScript.
842
1189
  */
843
1190
  this.JSONTypeDefinition = null;
1191
+ /**
1192
+ * Raw string representation of Configuration from metadata.
1193
+ */
1194
+ this._configuration = null;
1195
+ this._configurationObject = undefined;
844
1196
  this.RelatedEntityDisplayType = null;
845
1197
  this.EntityIDFieldName = null;
846
1198
  this.__mj_CreatedAt = null;
@@ -1040,6 +1392,24 @@ export class EntityFieldInfo extends BaseInfo {
1040
1392
  this.RelatedEntityClassName = null;
1041
1393
  this._rawEntityFieldValues = null;
1042
1394
  this._entityFieldValuesConstructed = false;
1395
+ /**
1396
+ * Memoized state for value-list validation. NOTE THE NAMES: none of these may be the getter's
1397
+ * name minus its underscore. `BaseInfo.toJSON` walks own keys, and for a `_`-prefixed one it
1398
+ * looks for a public getter of the matching PascalCase name and serializes THROUGH it. A memo
1399
+ * called `_valueListValuesForDisplay` would therefore add `ValueListValuesForDisplay` to every
1400
+ * serialized field — including the ~5,700 MJ core fields with no value list — bloating the
1401
+ * browser metadata cache and forcing EntityFieldValues hydration purely to serialize a string
1402
+ * that only ever appears in an error message. Hence `_valueListDisplayCache`.
1403
+ */
1404
+ this._normalizedValueListValues = undefined;
1405
+ /** Memoized message form of the value list, built on first validation failure. */
1406
+ this._valueListDisplayCache = undefined;
1407
+ /** Latches the broken-metadata error so a bulk load cannot emit it once per row. */
1408
+ this._loggedEmptyValueList = false;
1409
+ /** Latches the unsupported-value-type error for the same reason. */
1410
+ this._loggedUnsupportedValueListType = false;
1411
+ /** Memoized yyyy-mm-dd keys for a `date` field's value list; null when it cannot be compared. */
1412
+ this._valueListDateKeys = undefined;
1043
1413
  /**
1044
1414
  * Cached parsed RelatedEntityJoinFieldsConfig to avoid repeated JSON.parse calls.
1045
1415
  * Lazy-initialized on first access.
@@ -1207,6 +1577,40 @@ export class EntityInfo extends BaseInfo {
1207
1577
  const layered = this.GeneratedBaseViewName?.trim();
1208
1578
  return !!layered && layered.toLowerCase() !== (this.BaseView ?? '').toLowerCase();
1209
1579
  }
1580
+ /**
1581
+ * Optional JSON configuration bag (shape = {@link IEntityConfiguration}).
1582
+ * Nested `UI.Form` holds generated-form chrome: layout, auto left-nav
1583
+ * threshold, related-role policy, and the Primary related budget.
1584
+ * Parsed lazily on first access and cached using {@link SafeJSONParse}.
1585
+ *
1586
+ * @see packages/MJCore/src/generic/entityConfiguration.ts
1587
+ */
1588
+ get Configuration() {
1589
+ if (this._configurationObject === undefined) {
1590
+ this._configurationObject = this._configuration ? SafeJSONParse(this._configuration, false) : null;
1591
+ }
1592
+ return this._configurationObject;
1593
+ }
1594
+ set Configuration(value) {
1595
+ if (typeof value === 'string') {
1596
+ this._configuration = value;
1597
+ this._configurationObject = undefined;
1598
+ }
1599
+ else if (value && typeof value === 'object') {
1600
+ this._configurationObject = value;
1601
+ this._configuration = JSON.stringify(value);
1602
+ }
1603
+ else {
1604
+ this._configuration = null;
1605
+ this._configurationObject = null;
1606
+ }
1607
+ }
1608
+ /**
1609
+ * Parsed {@link Configuration}. Null when the column is empty or not valid JSON.
1610
+ */
1611
+ get ConfigurationObject() {
1612
+ return this.Configuration;
1613
+ }
1210
1614
  /**
1211
1615
  * O(1) case-insensitive field lookup by name. Use this instead of `Fields.find(f => f.Name === name)`
1212
1616
  * on hot paths — it builds a lowercased+trimmed `Map` once (lazily) and reuses it.
@@ -1784,6 +2188,16 @@ export class EntityInfo extends BaseInfo {
1784
2188
  * @returns
1785
2189
  */
1786
2190
  static BuildRelationshipViewParams(record, relationship, filter, maxRecords) {
2191
+ const joinFields = ReadRelationshipJoinFields(relationship.Configuration);
2192
+ if (joinFields && joinFields.length > 1) {
2193
+ const multi = EntityInfo.BuildRelationshipViewParamsForJoinFields(record, relationship.RelatedEntity, joinFields);
2194
+ if (filter && filter.length > 0 && multi.ExtraFilter) {
2195
+ multi.ExtraFilter = `(${multi.ExtraFilter}) AND (${filter})`;
2196
+ }
2197
+ if (maxRecords && maxRecords > 0)
2198
+ multi.MaxRows = maxRecords;
2199
+ return multi;
2200
+ }
1787
2201
  const params = {};
1788
2202
  let quotes = '';
1789
2203
  let keyValue = '';
@@ -1821,20 +2235,72 @@ export class EntityInfo extends BaseInfo {
1821
2235
  return params;
1822
2236
  }
1823
2237
  /**
1824
- * Builds a simple javascript object that will pre-populate a new record in the related entity with values that link back to the specified record.
1825
- * This is useful, for example, when creating a new contact from an account, we want to pre-populate the account ID in the new contact record
2238
+ * One related-entity grid over several join fields (Bill-To OR Ship-To).
2239
+ */
2240
+ static BuildRelationshipViewParamsForJoinFields(record, relatedEntityName, joinFields) {
2241
+ const fields = joinFields.map((f) => f.trim()).filter((f) => f.length > 0);
2242
+ if (fields.length === 0)
2243
+ return { EntityName: relatedEntityName };
2244
+ if (fields.length === 1) {
2245
+ const rel = record.EntityInfo.RelatedEntities.find((r) => r.RelatedEntity.trim().toLowerCase() === relatedEntityName.trim().toLowerCase()
2246
+ && r.RelatedEntityJoinField.trim().toLowerCase() === fields[0].toLowerCase());
2247
+ if (rel)
2248
+ return EntityInfo.BuildRelationshipViewParams(record, rel);
2249
+ }
2250
+ const firstKey = record.FirstPrimaryKey;
2251
+ const keyValue = firstKey.Value;
2252
+ const quotes = keyValue && firstKey.NeedsQuotes ? "'" : '';
2253
+ const clauses = fields.map((field) => `[${field}] = ${quotes}${keyValue}${quotes}`);
2254
+ return {
2255
+ EntityName: relatedEntityName,
2256
+ ExtraFilter: clauses.join(' OR '),
2257
+ };
2258
+ }
2259
+ /**
2260
+ * Default field values for a new related record so it links back to `record`.
2261
+ * When the relationship's Configuration declares `UI.join.fields`, every
2262
+ * listed FK is set (Bill-To AND Ship-To). Otherwise only
2263
+ * `RelatedEntityJoinField` is set.
1826
2264
  */
1827
2265
  static BuildRelationshipNewRecordValues(record, relationship) {
1828
- // we want to build a simple javascript object that will pre-populate a new record in the related entity with values that link
1829
- // abck to the current record. This is useful for example when creating a new contact from an account, we want to pre-populate the
1830
- // account ID in the new contact record
2266
+ if (!record || !relationship)
2267
+ return {};
2268
+ const joinFields = ReadRelationshipJoinFields(relationship.Configuration);
2269
+ if (joinFields && joinFields.length > 0) {
2270
+ return EntityInfo.BuildRelationshipNewRecordValuesForJoinFields(record, joinFields, relationship);
2271
+ }
2272
+ const joinField = (relationship.RelatedEntityJoinField ?? '').trim();
2273
+ if (!joinField)
2274
+ return {};
2275
+ return { [joinField]: EntityInfo.resolveRelationshipKeyValue(record, relationship) };
2276
+ }
2277
+ /**
2278
+ * Default field values for a new related record, setting every listed join
2279
+ * field to the parent key. Use this when one grid filters on several FKs
2280
+ * (Bill-To OR Ship-To) so "New" still auto-links the child to this parent.
2281
+ */
2282
+ static BuildRelationshipNewRecordValuesForJoinFields(record, joinFields, relationship) {
2283
+ if (!record)
2284
+ return {};
2285
+ const fields = joinFields.map((f) => f.trim()).filter((f) => f.length > 0);
2286
+ if (fields.length === 0)
2287
+ return {};
2288
+ const keyValue = EntityInfo.resolveRelationshipKeyValue(record, relationship);
1831
2289
  const obj = {};
1832
- if (record && relationship) {
1833
- const keyField = relationship.EntityKeyField && relationship.EntityKeyField.trim().length > 0 ? relationship.EntityKeyField : record.FirstPrimaryKey.Name;
1834
- obj[relationship.RelatedEntityJoinField] = record.Get(keyField);
2290
+ for (const field of fields) {
2291
+ obj[field] = keyValue;
1835
2292
  }
1836
2293
  return obj;
1837
2294
  }
2295
+ static resolveRelationshipKeyValue(record, relationship) {
2296
+ const explicit = relationship?.EntityKeyField?.trim();
2297
+ if (explicit)
2298
+ return record.Get(explicit);
2299
+ const first = record.FirstPrimaryKey;
2300
+ if (first?.Name)
2301
+ return record.Get(first.Name);
2302
+ return first?.Value;
2303
+ }
1838
2304
  /**
1839
2305
  * Returns a RunViewParams object configured to query the related entity matched via an organic key.
1840
2306
  * Supports both direct field matching and transitive matching via SQL views/tables.
@@ -1946,8 +2412,11 @@ export class EntityInfo extends BaseInfo {
1946
2412
  // Fall back to exact match if no custom expression defined
1947
2413
  return `${fieldExpression} = '${escapedValue}'`;
1948
2414
  }
1949
- const normalizedField = expr.replace(/\{\{FieldName\}\}/g, fieldExpression);
1950
- const normalizedValue = expr.replace(/\{\{FieldName\}\}/g, `'${escapedValue}'`);
2415
+ // Replacement functions: `escapedValue` is a data value, so `$&`/`` $` ``/
2416
+ // `$'`/`$$` in it would otherwise splice the custom expression's own text
2417
+ // into the SQL literal. See issue #3171.
2418
+ const normalizedField = expr.replace(/\{\{FieldName\}\}/g, () => fieldExpression);
2419
+ const normalizedValue = expr.replace(/\{\{FieldName\}\}/g, () => `'${escapedValue}'`);
1951
2420
  return `${normalizedField} = ${normalizedValue}`;
1952
2421
  }
1953
2422
  default:
@@ -1976,7 +2445,8 @@ export class EntityInfo extends BaseInfo {
1976
2445
  const expr = organicKey.CustomNormalizationExpression;
1977
2446
  if (!expr)
1978
2447
  return fieldExpression;
1979
- return expr.replace(/\{\{FieldName\}\}/g, fieldExpression);
2448
+ // Replacement function — see WrapWithNormalization (#3171).
2449
+ return expr.replace(/\{\{FieldName\}\}/g, () => fieldExpression);
1980
2450
  }
1981
2451
  default: return fieldExpression;
1982
2452
  }
@@ -1991,7 +2461,8 @@ export class EntityInfo extends BaseInfo {
1991
2461
  const expr = organicKey.CustomNormalizationExpression;
1992
2462
  if (!expr)
1993
2463
  return `'${escapedValue}'`;
1994
- return expr.replace(/\{\{FieldName\}\}/g, `'${escapedValue}'`);
2464
+ // Replacement function — see WrapWithNormalization (#3171).
2465
+ return expr.replace(/\{\{FieldName\}\}/g, () => `'${escapedValue}'`);
1995
2466
  }
1996
2467
  default: return `'${escapedValue}'`;
1997
2468
  }
@@ -2200,9 +2671,21 @@ export class EntityInfo extends BaseInfo {
2200
2671
  */
2201
2672
  this.AllowUserSearchAPI = false;
2202
2673
  /**
2203
- * Whether full-text search is enabled for this entity
2674
+ * Whether full text search is enabled for this entity
2204
2675
  */
2205
- this.FullTextSearchEnabled = false;
2676
+ this.FullTextSearchEnabled = null;
2677
+ /**
2678
+ * Whether CodeGen automatically updates FullTextSearchEnabled from database catalog/index availability.
2679
+ */
2680
+ this.AutoUpdateFullTextSearch = true;
2681
+ /**
2682
+ * Whether CodeGen automatically updates AllowUserSearchAPI from schema rules.
2683
+ */
2684
+ this.AutoUpdateAllowUserSearchAPI = true;
2685
+ /**
2686
+ * Whether external changes to records are detected.
2687
+ */
2688
+ this.DetectExternalChanges = false;
2206
2689
  /**
2207
2690
  * Name of the SQL Server full-text catalog used for searching
2208
2691
  */
@@ -2306,6 +2789,11 @@ export class EntityInfo extends BaseInfo {
2306
2789
  * CSS class or icon identifier for displaying this entity in the UI
2307
2790
  */
2308
2791
  this.Icon = null;
2792
+ /**
2793
+ * Raw string representation of Configuration from metadata.
2794
+ */
2795
+ this._configuration = null;
2796
+ this._configurationObject = undefined;
2309
2797
  /**
2310
2798
  * Date and time when this entity was created
2311
2799
  */