@memberjunction/metadata-sync 2.52.0 → 2.54.0

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.
package/README.md CHANGED
@@ -718,14 +718,27 @@ Examples:
718
718
  ### @lookup: References (ENHANCED)
719
719
  Enable entity relationships using human-readable values:
720
720
  - Basic syntax: `@lookup:EntityName.FieldName=Value`
721
+ - Multi-field syntax: `@lookup:EntityName.Field1=Value1&Field2=Value2`
721
722
  - Auto-create syntax: `@lookup:EntityName.FieldName=Value?create`
722
723
  - With additional fields: `@lookup:EntityName.FieldName=Value?create&Field2=Value2`
723
724
 
724
725
  Examples:
725
- - `@lookup:AI Prompt Types.Name=Chat` - Fails if not found
726
+ - `@lookup:AI Prompt Types.Name=Chat` - Single field lookup, fails if not found
727
+ - `@lookup:Users.Email=john@example.com&Department=Sales` - Multi-field lookup for precise matching
726
728
  - `@lookup:AI Prompt Categories.Name=Examples?create` - Creates if missing
727
729
  - `@lookup:AI Prompt Categories.Name=Examples?create&Description=Example prompts` - Creates with description
728
730
 
731
+ #### Multi-Field Lookups (NEW)
732
+ When you need to match records based on multiple criteria, use the multi-field syntax:
733
+ ```json
734
+ {
735
+ "CategoryID": "@lookup:AI Prompt Categories.Name=Actions&Status=Active",
736
+ "ManagerID": "@lookup:Users.Email=manager@company.com&Department=Engineering&Status=Active"
737
+ }
738
+ ```
739
+
740
+ This ensures you get the exact record you want when multiple records might have the same value in a single field.
741
+
729
742
  ### @parent: References (NEW)
730
743
  Reference fields from the immediate parent entity in embedded collections:
731
744
  - `@parent:ID` - Get the parent's ID field
@@ -743,6 +756,84 @@ Support environment-specific values:
743
756
  - `@env:VARIABLE_NAME`
744
757
  - Useful for different environments (dev/staging/prod)
745
758
 
759
+ ### {@include} References in Files (NEW)
760
+ Enable content composition within non-JSON files (like .md, .html, .txt) using JSDoc-style include syntax:
761
+ - Pattern: `{@include path/to/file.ext}`
762
+ - Supports relative paths from the containing file
763
+ - Recursive includes (includes within includes)
764
+ - Circular reference detection prevents infinite loops
765
+ - Works seamlessly with `@file:` references
766
+
767
+ #### How It Works
768
+ When a JSON metadata file uses `@file:` to reference an external file, the MetadataSync tool:
769
+ 1. Loads the referenced file
770
+ 2. Scans for `{@include}` patterns
771
+ 3. Recursively resolves all includes
772
+ 4. Returns the fully composed content
773
+
774
+ #### Example Usage
775
+ ```markdown
776
+ # My Prompt Template
777
+
778
+ ## System Instructions
779
+ {@include ./shared/system-instructions.md}
780
+
781
+ ## Context
782
+ {@include ../common/context-header.md}
783
+
784
+ ## Task
785
+ Please analyze the following...
786
+ ```
787
+
788
+ #### Complex Example with Nested Includes
789
+ Directory structure:
790
+ ```
791
+ prompts/
792
+ ├── customer-service/
793
+ │ ├── greeting.json # Uses @file:greeting.md
794
+ │ ├── greeting.md # Contains {@include} references
795
+ │ └── shared/
796
+ │ ├── tone.md
797
+ │ └── guidelines.md
798
+ └── common/
799
+ ├── company-info.md
800
+ └── legal-disclaimer.md
801
+ ```
802
+
803
+ greeting.json:
804
+ ```json
805
+ {
806
+ "fields": {
807
+ "Name": "Customer Greeting",
808
+ "Prompt": "@file:greeting.md"
809
+ }
810
+ }
811
+ ```
812
+
813
+ greeting.md:
814
+ ```markdown
815
+ # Customer Service Greeting
816
+
817
+ {@include ./shared/tone.md}
818
+
819
+ ## Guidelines
820
+ {@include ./shared/guidelines.md}
821
+
822
+ ## Company Information
823
+ {@include ../common/company-info.md}
824
+
825
+ ## Legal
826
+ {@include ../common/legal-disclaimer.md}
827
+ ```
828
+
829
+ The final content pushed to the database will have all includes fully resolved.
830
+
831
+ Benefits:
832
+ - **DRY Principle**: Share common content across multiple files
833
+ - **Maintainability**: Update shared content in one place
834
+ - **Flexibility**: Build complex documents from modular parts
835
+ - **Validation**: Automatic checking of included file existence and circular references
836
+
746
837
  ### @template: References (NEW)
747
838
  Enable JSON template composition for reusable configurations:
748
839
 
@@ -818,6 +909,9 @@ mj-sync validate --verbose
818
909
  # Validate with JSON output for CI/CD
819
910
  mj-sync validate --format=json
820
911
 
912
+ # Save validation report to markdown file
913
+ mj-sync validate --save-report
914
+
821
915
  # Initialize a directory for metadata sync
822
916
  mj-sync init
823
917
 
@@ -898,6 +992,53 @@ Directory order is configured in the root-level `.mj-sync.json` file only (not i
898
992
  2. **Categories → Items**: Create category records before items that reference them
899
993
  3. **Parent → Child**: Process parent entities before child entities with foreign key dependencies
900
994
 
995
+ ### Ignore Directories
996
+
997
+ The MetadataSync tool supports ignoring specific directories during push/pull operations. This is useful for:
998
+ - Excluding output or example directories from processing
999
+ - Skipping temporary or build directories
1000
+ - Organizing support files without them being processed as metadata
1001
+
1002
+ #### Configuration
1003
+
1004
+ Ignore directories are configured in `.mj-sync.json` files and are **cumulative** through the directory hierarchy:
1005
+
1006
+ ```json
1007
+ {
1008
+ "version": "1.0.0",
1009
+ "ignoreDirectories": [
1010
+ "output",
1011
+ "examples",
1012
+ "templates"
1013
+ ]
1014
+ }
1015
+ ```
1016
+
1017
+ #### How It Works
1018
+
1019
+ - **Cumulative Inheritance**: Each directory inherits ignore patterns from its parent directories
1020
+ - **Relative Paths**: Directory names are relative to the location of the `.mj-sync.json` file
1021
+ - **Simple Patterns**: Supports exact directory names (e.g., "output", "temp")
1022
+ - **Additive**: Child directories can add their own ignore patterns to parent patterns
1023
+
1024
+ #### Example
1025
+
1026
+ ```
1027
+ metadata/.mj-sync.json → ignoreDirectories: ["output", "temp"]
1028
+ ├── prompts/.mj-sync.json → ignoreDirectories: ["examples"]
1029
+ │ ├── output/ → IGNORED (from root)
1030
+ │ ├── examples/ → IGNORED (from prompts)
1031
+ │ └── production/.mj-sync.json → ignoreDirectories: ["drafts"]
1032
+ │ ├── drafts/ → IGNORED (from production)
1033
+ │ └── output/ → IGNORED (inherited from root)
1034
+ ```
1035
+
1036
+ In this example:
1037
+ - Root level ignores "output" and "temp" everywhere
1038
+ - Prompts directory adds "examples" to the ignore list
1039
+ - Production subdirectory further adds "drafts"
1040
+ - All patterns are cumulative, so production inherits all parent ignores
1041
+
901
1042
  ### SQL Logging (NEW)
902
1043
 
903
1044
  The MetadataSync tool now supports SQL logging for capturing all database operations during push commands. This feature is useful for:
@@ -973,6 +1114,75 @@ Migration files include:
973
1114
 
974
1115
  The SQL logging runs in parallel with the actual database operations, ensuring minimal performance impact while capturing all SQL statements for review and potential migration use.
975
1116
 
1117
+ ### User Role Validation (NEW)
1118
+
1119
+ MetadataSync now supports validating UserID fields against specific roles in the MemberJunction system. This ensures that only users with appropriate roles can be referenced in metadata files.
1120
+
1121
+ #### Configuration
1122
+
1123
+ Add the `userRoleValidation` configuration to your root `.mj-sync.json` file:
1124
+
1125
+ ```json
1126
+ {
1127
+ "version": "1.0.0",
1128
+ "userRoleValidation": {
1129
+ "enabled": true,
1130
+ "allowedRoles": [
1131
+ "Administrator",
1132
+ "Developer",
1133
+ "Content Manager"
1134
+ ],
1135
+ "allowUsersWithoutRoles": false
1136
+ }
1137
+ }
1138
+ ```
1139
+
1140
+ #### Configuration Options
1141
+
1142
+ | Option | Type | Default | Description |
1143
+ |--------|------|---------|-------------|
1144
+ | `enabled` | boolean | false | Enable user role validation for UserID fields |
1145
+ | `allowedRoles` | string[] | [] | List of role names that are allowed |
1146
+ | `allowUsersWithoutRoles` | boolean | false | Allow users without any assigned roles |
1147
+
1148
+ #### How It Works
1149
+
1150
+ 1. During validation, all user roles are loaded from the database and cached
1151
+ 2. For each UserID field in metadata files, the validator checks:
1152
+ - If the user exists and has roles assigned
1153
+ - If the user has at least one of the allowed roles
1154
+ 3. Validation fails if:
1155
+ - A UserID references a user without any roles (unless `allowUsersWithoutRoles` is true)
1156
+ - A UserID references a user whose roles are not in the `allowedRoles` list
1157
+
1158
+ #### Example
1159
+
1160
+ Given a metadata file with a UserID field:
1161
+
1162
+ ```json
1163
+ {
1164
+ "fields": {
1165
+ "Name": "Admin Action",
1166
+ "UserID": "user-123"
1167
+ }
1168
+ }
1169
+ ```
1170
+
1171
+ The validation will:
1172
+ 1. Check if user-123 exists in the system
1173
+ 2. Verify that user-123 has one of the allowed roles
1174
+ 3. Report an error if the user doesn't have appropriate roles
1175
+
1176
+ #### Error Messages
1177
+
1178
+ ```
1179
+ ✗ UserID 'user-123' does not have any assigned roles
1180
+ Suggestion: User must have one of these roles: Administrator, Developer
1181
+
1182
+ ✗ UserID 'user-456' has roles [Viewer] but none are in allowed list
1183
+ Suggestion: Allowed roles: Administrator, Developer, Content Manager
1184
+ ```
1185
+
976
1186
  ### Root Configuration (metadata/.mj-sync.json)
977
1187
  ```json
978
1188
  {
@@ -990,6 +1200,11 @@ The SQL logging runs in parallel with the actual database operations, ensuring m
990
1200
  "outputDirectory": "./sql_logging",
991
1201
  "formatAsMigration": false
992
1202
  },
1203
+ "userRoleValidation": {
1204
+ "enabled": true,
1205
+ "allowedRoles": ["Administrator", "Developer"],
1206
+ "allowUsersWithoutRoles": false
1207
+ },
993
1208
  "watch": {
994
1209
  "debounceMs": 1000,
995
1210
  "ignorePatterns": ["*.tmp", "*.bak"]
@@ -26,6 +26,10 @@ export default class Push extends Command {
26
26
  * Check if a record has already been processed and warn if duplicate
27
27
  */
28
28
  private checkAndTrackRecord;
29
+ /**
30
+ * Format field value for console display
31
+ */
32
+ private formatFieldValue;
29
33
  /**
30
34
  * Parse JSON file and track line numbers for array elements
31
35
  */
@@ -137,7 +137,7 @@ class Push extends core_1.Command {
137
137
  }
138
138
  }
139
139
  // Find entity directories to process
140
- const entityDirs = (0, provider_utils_1.findEntityDirectories)(config_manager_1.configManager.getOriginalCwd(), flags.dir, syncConfig?.directoryOrder);
140
+ const entityDirs = (0, provider_utils_1.findEntityDirectories)(config_manager_1.configManager.getOriginalCwd(), flags.dir, syncConfig?.directoryOrder, syncConfig?.ignoreDirectories);
141
141
  if (entityDirs.length === 0) {
142
142
  this.error('No entity directories found');
143
143
  }
@@ -168,7 +168,9 @@ class Push extends core_1.Command {
168
168
  default: false
169
169
  });
170
170
  if (!shouldContinue) {
171
- this.error('Push cancelled due to validation errors.');
171
+ this.log(chalk_1.default.yellow('\n⚠️ Push cancelled due to validation errors.'));
172
+ // Exit cleanly without throwing an error
173
+ return;
172
174
  }
173
175
  }
174
176
  }
@@ -249,7 +251,12 @@ class Push extends core_1.Command {
249
251
  if (flags.verbose) {
250
252
  this.log(`\nProcessing ${entityConfig.entity} in ${entityDir}`);
251
253
  }
252
- const result = await this.processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager);
254
+ // Combine root ignoreDirectories with entity-level ignoreDirectories
255
+ const initialIgnoreDirectories = [
256
+ ...(syncConfig?.ignoreDirectories || []),
257
+ ...(entityConfig?.ignoreDirectories || [])
258
+ ];
259
+ const result = await this.processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager, initialIgnoreDirectories);
253
260
  // Show per-directory summary
254
261
  const dirName = path_1.default.relative(process.cwd(), entityDir) || '.';
255
262
  const dirTotal = result.created + result.updated + result.unchanged;
@@ -444,7 +451,7 @@ class Push extends core_1.Command {
444
451
  process.exit(0);
445
452
  }
446
453
  }
447
- async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager) {
454
+ async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager, parentIgnoreDirectories) {
448
455
  const result = { created: 0, updated: 0, unchanged: 0, errors: 0 };
449
456
  // Find files matching the configured pattern
450
457
  const pattern = entityConfig.filePattern || '*.json';
@@ -454,6 +461,56 @@ class Push extends core_1.Command {
454
461
  dot: true, // Include dotfiles (files starting with .)
455
462
  onlyFiles: true
456
463
  });
464
+ // Check if no JSON files were found
465
+ if (jsonFiles.length === 0) {
466
+ const relativePath = path_1.default.relative(process.cwd(), entityDir) || '.';
467
+ const parentPath = path_1.default.dirname(entityDir);
468
+ const dirName = path_1.default.basename(entityDir);
469
+ // Check if this is a subdirectory (not a top-level entity directory)
470
+ const isSubdirectory = parentPath !== path_1.default.resolve(config_manager_1.configManager.getOriginalCwd(), flags.dir || '.');
471
+ if (isSubdirectory) {
472
+ // For subdirectories, make it a warning instead of an error
473
+ let warningMessage = `No JSON files found in ${relativePath} matching pattern: ${pattern}`;
474
+ // Try to be more helpful by checking what files do exist
475
+ const allFiles = await (0, fast_glob_1.default)('*', {
476
+ cwd: entityDir,
477
+ onlyFiles: true,
478
+ dot: true
479
+ });
480
+ if (allFiles.length > 0) {
481
+ warningMessage += `\n Files found: ${allFiles.slice(0, 3).join(', ')}`;
482
+ if (allFiles.length > 3) {
483
+ warningMessage += ` (and ${allFiles.length - 3} more)`;
484
+ }
485
+ }
486
+ const rootConfigPath = path_1.default.join(config_manager_1.configManager.getOriginalCwd(), flags.dir || '.', '.mj-sync.json');
487
+ warningMessage += `\n 💡 If this directory should be ignored, add "${dirName}" to the "ignoreDirectories" array in:\n ${rootConfigPath}`;
488
+ this.warn(warningMessage);
489
+ return result; // Return early without processing further
490
+ }
491
+ else {
492
+ // For top-level entity directories, this is still an error
493
+ const configFile = path_1.default.join(entityDir, '.mj-sync.json');
494
+ let errorMessage = `No JSON files found in ${relativePath} matching pattern: ${pattern}\n`;
495
+ errorMessage += `\nPlease check:\n`;
496
+ errorMessage += ` 1. Files exist with the expected extension (.json)\n`;
497
+ errorMessage += ` 2. The filePattern in ${configFile} matches your files\n`;
498
+ errorMessage += ` 3. Files are not in ignored patterns: .mj-sync.json, .mj-folder.json, *.backup\n`;
499
+ // Try to be more helpful by checking what files do exist
500
+ const allFiles = await (0, fast_glob_1.default)('*', {
501
+ cwd: entityDir,
502
+ onlyFiles: true,
503
+ dot: true
504
+ });
505
+ if (allFiles.length > 0) {
506
+ errorMessage += `\nFiles found in directory: ${allFiles.slice(0, 5).join(', ')}`;
507
+ if (allFiles.length > 5) {
508
+ errorMessage += ` (and ${allFiles.length - 5} more)`;
509
+ }
510
+ }
511
+ throw new Error(errorMessage);
512
+ }
513
+ }
457
514
  if (flags.verbose) {
458
515
  this.log(`Processing ${jsonFiles.length} records in ${path_1.default.relative(process.cwd(), entityDir) || '.'}`);
459
516
  }
@@ -463,6 +520,24 @@ class Push extends core_1.Command {
463
520
  const entries = await fs_extra_1.default.readdir(entityDir, { withFileTypes: true });
464
521
  for (const entry of entries) {
465
522
  if (entry.isDirectory() && !entry.name.startsWith('.')) {
523
+ // Build cumulative ignore list: parent + current directory's ignores
524
+ const currentDirConfig = await (0, config_1.loadSyncConfig)(entityDir);
525
+ const currentEntityConfig = await (0, config_1.loadEntityConfig)(entityDir);
526
+ const cumulativeIgnoreDirectories = [
527
+ ...(parentIgnoreDirectories || []),
528
+ ...(currentDirConfig?.ignoreDirectories || []),
529
+ ...(currentEntityConfig?.ignoreDirectories || [])
530
+ ];
531
+ // Check if this directory should be ignored
532
+ if (cumulativeIgnoreDirectories.some((pattern) => {
533
+ // Simple pattern matching: exact name or ends with pattern
534
+ return entry.name === pattern || entry.name.endsWith(pattern);
535
+ })) {
536
+ if (flags.verbose) {
537
+ this.log(` Ignoring directory: ${entry.name} (matched ignore pattern)`);
538
+ }
539
+ continue;
540
+ }
466
541
  const subDir = path_1.default.join(entityDir, entry.name);
467
542
  // Load subdirectory config and merge with parent config
468
543
  let subEntityConfig = { ...entityConfig };
@@ -483,8 +558,8 @@ class Push extends core_1.Command {
483
558
  }
484
559
  };
485
560
  }
486
- // Process subdirectory with merged config
487
- const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig, fileBackupManager);
561
+ // Process subdirectory with merged config and cumulative ignore directories
562
+ const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig, fileBackupManager, cumulativeIgnoreDirectories);
488
563
  result.created += subResult.created;
489
564
  result.updated += subResult.updated;
490
565
  result.unchanged += subResult.unchanged;
@@ -558,6 +633,7 @@ class Push extends core_1.Command {
558
633
  const fullErrorMessage = `Failed to process ${file}: ${errorMessage}`;
559
634
  this.errors.push(fullErrorMessage);
560
635
  this.error(fullErrorMessage, { exit: false });
636
+ this.log(' ⚠️ This error will cause all changes to be rolled back at the end of processing');
561
637
  }
562
638
  }
563
639
  if (flags.verbose) {
@@ -640,7 +716,7 @@ class Push extends core_1.Command {
640
716
  try {
641
717
  const processedValue = await syncEngine.processFieldValue(value, baseDir, null, null);
642
718
  if (verbose) {
643
- this.log(` Setting ${field}: ${JSON.stringify(value)} -> ${JSON.stringify(processedValue)}`);
719
+ this.log(` Setting ${field}: ${this.formatFieldValue(value)} -> ${this.formatFieldValue(processedValue)}`);
644
720
  }
645
721
  entity[field] = processedValue;
646
722
  }
@@ -688,7 +764,7 @@ class Push extends core_1.Command {
688
764
  const field = entity.GetFieldByName(fieldName);
689
765
  const oldValue = field ? field.OldValue : undefined;
690
766
  const newValue = changes[fieldName];
691
- this.log(` ${fieldName}: ${oldValue} → ${newValue}`);
767
+ this.log(` ${fieldName}: ${this.formatFieldValue(oldValue)} → ${this.formatFieldValue(newValue)}`);
692
768
  }
693
769
  }
694
770
  }
@@ -814,7 +890,7 @@ class Push extends core_1.Command {
814
890
  try {
815
891
  const processedValue = await syncEngine.processFieldValue(value, baseDir, parentEntity, rootEntity);
816
892
  if (verbose) {
817
- this.log(`${indent} Setting ${field}: ${JSON.stringify(value)} -> ${JSON.stringify(processedValue)}`);
893
+ this.log(`${indent} Setting ${field}: ${this.formatFieldValue(value)} -> ${this.formatFieldValue(processedValue)}`);
818
894
  }
819
895
  entity[field] = processedValue;
820
896
  }
@@ -859,7 +935,7 @@ class Push extends core_1.Command {
859
935
  const field = entity.GetFieldByName(fieldName);
860
936
  const oldValue = field ? field.OldValue : undefined;
861
937
  const newValue = changes[fieldName];
862
- this.log(`${indent} ${fieldName}: ${oldValue} → ${newValue}`);
938
+ this.log(`${indent} ${fieldName}: ${this.formatFieldValue(oldValue)} → ${this.formatFieldValue(newValue)}`);
863
939
  }
864
940
  }
865
941
  }
@@ -973,6 +1049,20 @@ class Push extends core_1.Command {
973
1049
  });
974
1050
  return false; // not duplicate
975
1051
  }
1052
+ /**
1053
+ * Format field value for console display
1054
+ */
1055
+ formatFieldValue(value, maxLength = 50) {
1056
+ // Convert value to string representation
1057
+ let strValue = JSON.stringify(value);
1058
+ // Trim the string
1059
+ strValue = strValue.trim();
1060
+ // If it's longer than maxLength, truncate and add ellipsis
1061
+ if (strValue.length > maxLength) {
1062
+ return strValue.substring(0, maxLength) + '...';
1063
+ }
1064
+ return strValue;
1065
+ }
976
1066
  /**
977
1067
  * Parse JSON file and track line numbers for array elements
978
1068
  */