@memberjunction/metadata-sync 2.47.0 → 2.48.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
@@ -104,10 +104,24 @@ The tool uses a hierarchical directory structure with cascading defaults:
104
104
  - Each top-level directory represents an entity type
105
105
  - `.mj-sync.json` files define entities and base defaults
106
106
  - `.mj-folder.json` files define folder-specific defaults (optional)
107
- - All JSON files within are treated as records of that entity type
107
+ - Only dot-prefixed JSON files (e.g., `.prompt-template.json`, `.category.json`) are treated as metadata records
108
+ - Regular JSON files without the dot prefix are ignored, allowing package.json and other config files to coexist
108
109
  - External files (`.md`, `.html`, etc.) are referenced from the JSON files
109
110
  - Defaults cascade down through the folder hierarchy
110
111
 
112
+ ### File Naming Convention
113
+
114
+ **Metadata files must be prefixed with a dot (.)** to be recognized by the sync tool. This convention:
115
+ - Clearly distinguishes metadata files from regular configuration files
116
+ - Allows `package.json`, `tsconfig.json` and other standard files to coexist without being processed
117
+ - Follows established patterns like `.gitignore` and `.eslintrc.json`
118
+
119
+ Examples:
120
+ - ✅ `.greeting.json` - Will be processed as metadata
121
+ - ✅ `.customer-prompt.json` - Will be processed as metadata
122
+ - ❌ `greeting.json` - Will be ignored
123
+ - ❌ `package.json` - Will be ignored
124
+
111
125
  ### File Format Options
112
126
 
113
127
  #### Single Record per File (Default)
@@ -147,12 +161,12 @@ metadata/
147
161
  │ ├── .mj-sync.json # Defines entity: "AI Prompts"
148
162
  │ ├── customer-service/
149
163
  │ │ ├── .mj-folder.json # Folder metadata (CategoryID, etc.)
150
- │ │ ├── greeting.json # AI Prompt record with embedded models
164
+ │ │ ├── .greeting.json # AI Prompt record with embedded models
151
165
  │ │ ├── greeting.prompt.md # Prompt content (referenced)
152
166
  │ │ └── greeting.notes.md # Notes field (referenced)
153
167
  │ └── analytics/
154
168
  │ ├── .mj-folder.json # Folder metadata (CategoryID, etc.)
155
- │ ├── daily-report.json # AI Prompt record
169
+ │ ├── .daily-report.json # AI Prompt record
156
170
  │ └── daily-report.prompt.md # Prompt content (referenced)
157
171
  ├── templates/ # Reusable JSON templates
158
172
  │ ├── standard-prompt-settings.json # Common prompt configurations
@@ -163,17 +177,17 @@ metadata/
163
177
  ├── .mj-sync.json # Defines entity: "Templates"
164
178
  ├── email/
165
179
  │ ├── .mj-folder.json # Folder metadata
166
- │ ├── welcome.json # Template record
180
+ │ ├── .welcome.json # Template record (dot-prefixed)
167
181
  │ └── welcome.template.html # Template content (referenced)
168
182
  └── reports/
169
183
  ├── .mj-folder.json # Folder metadata
170
- ├── invoice.json # Template record
184
+ ├── .invoice.json # Template record (dot-prefixed)
171
185
  └── invoice.template.html # Template content (referenced)
172
186
  ```
173
187
 
174
188
  ## JSON Metadata Format
175
189
 
176
- ### Individual Record (e.g., ai-prompts/customer-service/greeting.json)
190
+ ### Individual Record (e.g., ai-prompts/customer-service/.greeting.json)
177
191
  ```json
178
192
  {
179
193
  "fields": {
@@ -476,7 +490,7 @@ Configuration follows a hierarchical structure:
476
490
  ```json
477
491
  {
478
492
  "entity": "AI Prompts",
479
- "filePattern": "*.json",
493
+ "filePattern": ".*.json",
480
494
  "defaults": {
481
495
  "TypeID": "@lookup:AI Prompt Types.Name=Chat",
482
496
  "Temperature": 0.7,
@@ -484,11 +498,21 @@ Configuration follows a hierarchical structure:
484
498
  "Status": "Active"
485
499
  },
486
500
  "pull": {
501
+ "filePattern": ".*.json",
502
+ "updateExistingRecords": true,
503
+ "createNewFileIfNotFound": true,
504
+ "mergeStrategy": "merge",
487
505
  "filter": "Status = 'Active'",
506
+ "externalizeFields": [
507
+ {
508
+ "field": "Prompt",
509
+ "pattern": "@file:{Name}.prompt.md"
510
+ }
511
+ ],
488
512
  "relatedEntities": {
489
513
  "MJ: AI Prompt Models": {
490
514
  "entity": "MJ: AI Prompt Models",
491
- "foreignKey": "ID",
515
+ "foreignKey": "PromptID",
492
516
  "filter": "Status = 'Active'"
493
517
  }
494
518
  }
@@ -529,26 +553,204 @@ The tool now supports managing related entities as embedded collections within p
529
553
  - **Relationship Clarity**: Visual representation of data relationships
530
554
 
531
555
  ### Configuration for Pull
532
- Configure which related entities to pull in `.mj-sync.json`:
556
+
557
+ The pull command now supports smart update capabilities with extensive configuration options:
558
+
533
559
  ```json
534
560
  {
535
561
  "entity": "AI Prompts",
562
+ "filePattern": ".*.json",
536
563
  "pull": {
564
+ "filePattern": ".*.json",
565
+ "createNewFileIfNotFound": true,
566
+ "newFileName": ".all-new.json",
567
+ "appendRecordsToExistingFile": true,
568
+ "updateExistingRecords": true,
569
+ "preserveFields": ["customField", "localNotes"],
570
+ "mergeStrategy": "merge",
571
+ "backupBeforeUpdate": true,
572
+ "filter": "Status = 'Active'",
573
+ "externalizeFields": [
574
+ {
575
+ "field": "TemplateText",
576
+ "pattern": "@file:{Name}.template.md"
577
+ },
578
+ {
579
+ "field": "PromptText",
580
+ "pattern": "@file:prompts/{Name}.prompt.md"
581
+ }
582
+ ],
583
+ "excludeFields": ["InternalID", "TempField"],
584
+ "lookupFields": {
585
+ "CategoryID": {
586
+ "entity": "AI Prompt Categories",
587
+ "field": "Name"
588
+ },
589
+ "TypeID": {
590
+ "entity": "AI Prompt Types",
591
+ "field": "Name"
592
+ }
593
+ },
537
594
  "relatedEntities": {
538
595
  "MJ: AI Prompt Models": {
539
596
  "entity": "MJ: AI Prompt Models",
540
- "foreignKey": "ID",
541
- "filter": "Status = 'Active'"
542
- },
543
- "AI Prompt Parameters": {
544
- "entity": "AI Prompt Parameters",
545
- "foreignKey": "ID"
597
+ "foreignKey": "PromptID",
598
+ "filter": "Status = 'Active'",
599
+ "lookupFields": {
600
+ "ModelID": {
601
+ "entity": "AI Models",
602
+ "field": "Name"
603
+ }
604
+ }
546
605
  }
547
606
  }
548
607
  }
549
608
  }
550
609
  ```
551
610
 
611
+ #### Pull Configuration Options
612
+
613
+ | Option | Type | Default | Description |
614
+ |--------|------|---------|-------------|
615
+ | `filePattern` | string | Entity filePattern | Pattern for finding existing files to update |
616
+ | `createNewFileIfNotFound` | boolean | true | Create files for records not found locally |
617
+ | `newFileName` | string | - | Filename for new records when appending (see warning below) |
618
+ | `appendRecordsToExistingFile` | boolean | false | Append new records to a single file |
619
+ | `updateExistingRecords` | boolean | true | Update existing records found in local files |
620
+ | `preserveFields` | string[] | [] | Fields that retain local values during updates (see detailed explanation below) |
621
+ | `mergeStrategy` | string | "merge" | How to merge updates: "merge", "overwrite", or "skip" |
622
+ | `backupBeforeUpdate` | boolean | false | Create timestamped backups before updating files |
623
+ | `backupDirectory` | string | ".backups" | Directory name for backup files (relative to entity directory) |
624
+ | `filter` | string | - | SQL WHERE clause for filtering records |
625
+ | `externalizeFields` | array/object | - | Fields to save as external files with optional patterns |
626
+ | `excludeFields` | string[] | [] | Fields to completely omit from pulled data (see detailed explanation below) |
627
+ | `lookupFields` | object | - | Foreign keys to convert to @lookup references |
628
+ | `relatedEntities` | object | - | Related entities to pull as embedded collections |
629
+
630
+ > **⚠️ Important Configuration Warning**
631
+ >
632
+ > When both `appendRecordsToExistingFile: true` and `newFileName` are set, ALL new records will be appended to the single file specified by `newFileName`, effectively ignoring the standard per-record file pattern. This can lead to unexpected file organization:
633
+ >
634
+ > ```json
635
+ > // This configuration will put ALL new records in .all-new.json
636
+ > "pull": {
637
+ > "appendRecordsToExistingFile": true,
638
+ > "newFileName": ".all-new.json" // ⚠️ Overrides individual file creation
639
+ > }
640
+ > ```
641
+ >
642
+ > **Recommended configurations:**
643
+ > - For individual files per record: Set `appendRecordsToExistingFile: false` (or omit it)
644
+ > - For grouped new records: Set both `appendRecordsToExistingFile: true` and `newFileName`
645
+ > - For mixed approach: Omit `newFileName` to let new records follow the standard pattern
646
+
647
+ #### Merge Strategies
648
+
649
+ - **`merge`** (default): Combines fields from database and local file, with database values taking precedence for existing fields
650
+ - **`overwrite`**: Completely replaces local record with database version (except preserved fields)
651
+ - **`skip`**: Leaves existing records unchanged, only adds new records
652
+
653
+ #### Understanding excludeFields vs preserveFields
654
+
655
+ These two configuration options serve different purposes for managing fields during pull operations:
656
+
657
+ ##### excludeFields
658
+ - **Purpose**: Completely omit specified fields from your local files
659
+ - **Use Case**: Remove internal/system fields you don't want in version control
660
+ - **Effect**: Fields never appear in the JSON files
661
+ - **Example**: Excluding internal IDs, timestamps, or sensitive data
662
+
663
+ ##### preserveFields
664
+ - **Purpose**: Protect local customizations from being overwritten during updates
665
+ - **Use Case**: Keep locally modified values while updating other fields
666
+ - **Effect**: Fields exist in files but retain their local values during pull
667
+ - **Example**: Preserving custom file paths, local notes, or environment-specific values
668
+ - **Special Behavior for @file: references**: When a preserved field contains a `@file:` reference, the tool will update the content at the existing file path rather than creating a new file with a generated name
669
+
670
+ ##### Example Configuration
671
+ ```json
672
+ {
673
+ "pull": {
674
+ "excludeFields": ["TemplateID", "InternalNotes", "CreatedAt"],
675
+ "preserveFields": ["TemplateText", "OutputExample", "LocalConfig"]
676
+ }
677
+ }
678
+ ```
679
+
680
+ With this configuration:
681
+ - **TemplateID, InternalNotes, CreatedAt** → Never appear in local files
682
+ - **TemplateText, OutputExample, LocalConfig** → Keep their local values during updates
683
+
684
+ ##### Common Scenario: Customized File References
685
+ When you customize file paths (e.g., changing `@file:templates/skip-conductor.md` to `@file:templates/conductor.md`), use `preserveFields` to protect these customizations:
686
+
687
+ ```json
688
+ {
689
+ "pull": {
690
+ "preserveFields": ["TemplateText", "OutputExample"],
691
+ "externalizeFields": [
692
+ {
693
+ "field": "TemplateText",
694
+ "pattern": "@file:templates/{Name}.template.md"
695
+ }
696
+ ]
697
+ }
698
+ }
699
+ ```
700
+
701
+ This ensures your custom paths aren't overwritten when pulling updates from the database.
702
+
703
+ **How it works:**
704
+ 1. **Without preserveFields**: Pull would create a new file using the pattern (e.g., `templates/skip-conductor.template.md`) and update the JSON to point to it
705
+ 2. **With preserveFields**: Pull keeps your custom path (e.g., `@file:templates/conductor.md`) in the JSON and updates the content at that existing location
706
+
707
+ This is particularly useful when:
708
+ - You've reorganized your file structure after initial pull
709
+ - You've renamed files to follow your own naming conventions
710
+ - You want to maintain consistent paths across team members
711
+
712
+ #### Backup Configuration
713
+
714
+ When `backupBeforeUpdate` is enabled, the tool creates timestamped backups before updating existing files:
715
+
716
+ - **Backup Location**: Files are backed up to the `backupDirectory` (default: `.backups`) within the entity directory
717
+ - **Backup Naming**: Original filename + timestamp + `.backup` extension (e.g., `.greeting.json` → `.greeting.2024-03-15T10-30-45-123Z.backup`)
718
+ - **Extension**: All backup files use the `.backup` extension, preventing them from being processed by push/pull/status commands
719
+ - **Deduplication**: Only one backup is created per file per pull operation, even if the file contains multiple records
720
+
721
+ Example configuration:
722
+ ```json
723
+ "pull": {
724
+ "backupBeforeUpdate": true,
725
+ "backupDirectory": ".backups" // Custom backup directory name
726
+ }
727
+ ```
728
+
729
+ #### Externalize Fields Patterns
730
+
731
+ The `externalizeFields` configuration supports dynamic file naming with placeholders:
732
+
733
+ ```json
734
+ "externalizeFields": [
735
+ {
736
+ "field": "TemplateText",
737
+ "pattern": "@file:{Name}.template.md"
738
+ },
739
+ {
740
+ "field": "SQLQuery",
741
+ "pattern": "@file:queries/{CategoryName}/{Name}.sql"
742
+ }
743
+ ]
744
+ ```
745
+
746
+ Supported placeholders:
747
+ - `{Name}` - The entity's name field value
748
+ - `{ID}` - The entity's primary key
749
+ - `{FieldName}` - The field being externalized
750
+ - `{AnyFieldName}` - Any field from the entity record
751
+
752
+ All values are sanitized for filesystem compatibility (lowercase, spaces to hyphens, special characters removed).
753
+
552
754
  ### Nested Related Entities
553
755
  Support for multiple levels of nesting:
554
756
  ```json
@@ -37,6 +37,7 @@ export default class Pull extends Command {
37
37
  filter: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
38
38
  'dry-run': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
39
39
  'multi-file': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
40
+ verbose: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
40
41
  };
41
42
  run(): Promise<void>;
42
43
  /**
@@ -77,20 +78,36 @@ export default class Pull extends Command {
77
78
  * @param targetDir - Directory where files will be saved
78
79
  * @param entityConfig - Entity configuration with defaults and settings
79
80
  * @param syncEngine - Sync engine for checksum calculation
81
+ * @param flags - Command flags
82
+ * @param isNewRecord - Whether this is a new record
83
+ * @param existingRecordData - Existing record data to preserve field selection
80
84
  * @returns Promise resolving to formatted RecordData
81
85
  * @private
82
86
  */
83
87
  private processRecordData;
88
+ /**
89
+ * Convert a foreign key value to a @lookup reference
90
+ *
91
+ * Looks up the related record and creates a @lookup string that can be
92
+ * resolved during push operations.
93
+ *
94
+ * @param foreignKeyValue - The foreign key value (ID)
95
+ * @param targetEntity - Name of the target entity
96
+ * @param targetField - Field in target entity to use for lookup
97
+ * @param syncEngine - Sync engine instance
98
+ * @returns @lookup string or null if lookup fails
99
+ * @private
100
+ */
101
+ private convertToLookup;
84
102
  /**
85
103
  * Determine if a field should be saved to an external file
86
104
  *
87
- * Checks if a field contains substantial text content that would be better
88
- * stored in a separate file rather than inline in the JSON. Uses heuristics
89
- * based on field name and content length.
105
+ * Checks if a field is configured for externalization or contains substantial
106
+ * text content that would be better stored in a separate file.
90
107
  *
91
108
  * @param fieldName - Name of the field to check
92
109
  * @param fieldValue - Value of the field
93
- * @param entityConfig - Entity configuration (for future extension)
110
+ * @param entityConfig - Entity configuration with externalization settings
94
111
  * @returns Promise resolving to true if field should be externalized
95
112
  * @private
96
113
  */
@@ -101,11 +118,14 @@ export default class Pull extends Command {
101
118
  * Saves large text content to a separate file and returns the filename.
102
119
  * Automatically determines appropriate file extension based on field name
103
120
  * and content type (e.g., .md for prompts, .html for templates).
121
+ * Uses the entity's name field for the filename if available.
104
122
  *
105
123
  * @param targetDir - Directory to save the file
106
- * @param primaryKey - Primary key for filename generation
124
+ * @param record - Full record to extract name field from
125
+ * @param primaryKey - Primary key for filename generation fallback
107
126
  * @param fieldName - Name of the field being externalized
108
127
  * @param content - Content to write to the file
128
+ * @param entityConfig - Entity configuration
109
129
  * @returns Promise resolving to the created filename
110
130
  * @private
111
131
  */
@@ -116,6 +136,7 @@ export default class Pull extends Command {
116
136
  * Creates a safe filename based on the entity's primary key values.
117
137
  * Handles GUIDs by using first 8 characters, sanitizes special characters,
118
138
  * and creates composite names for multi-field keys.
139
+ * Files are prefixed with a dot to follow the metadata file convention.
119
140
  *
120
141
  * @param primaryKey - Primary key fields and values
121
142
  * @param entityConfig - Entity configuration (for future extension)
@@ -150,4 +171,68 @@ export default class Pull extends Command {
150
171
  * @private
151
172
  */
152
173
  private findParentField;
174
+ /**
175
+ * Find existing files in a directory matching a pattern
176
+ *
177
+ * Searches for files that match the configured file pattern, used to identify
178
+ * which records already exist locally for smart update functionality.
179
+ *
180
+ * @param dir - Directory to search in
181
+ * @param pattern - Glob pattern to match files (e.g., "*.json")
182
+ * @returns Promise resolving to array of file paths
183
+ * @private
184
+ */
185
+ private findExistingFiles;
186
+ /**
187
+ * Load existing records from files and build a lookup map
188
+ *
189
+ * Reads all existing files and creates a map from primary key to file location,
190
+ * enabling efficient lookup during the update process.
191
+ *
192
+ * @param files - Array of file paths to load
193
+ * @param entityInfo - Entity metadata for primary key information
194
+ * @returns Map from primary key string to file info
195
+ * @private
196
+ */
197
+ private loadExistingRecords;
198
+ /**
199
+ * Create a string lookup key from primary key values
200
+ *
201
+ * Generates a consistent string representation of primary key values
202
+ * for use in maps and comparisons.
203
+ *
204
+ * @param primaryKey - Primary key field names and values
205
+ * @returns String representation of the primary key
206
+ * @private
207
+ */
208
+ private createPrimaryKeyLookup;
209
+ /**
210
+ * Merge two record data objects based on configured strategy
211
+ *
212
+ * Combines existing and new record data according to the merge strategy:
213
+ * - 'overwrite': Replace all fields with new values
214
+ * - 'merge': Combine fields, with new values taking precedence
215
+ * - 'skip': Keep existing record unchanged
216
+ *
217
+ * @param existing - Existing record data
218
+ * @param newData - New record data from database
219
+ * @param strategy - Merge strategy to apply
220
+ * @param preserveFields - Field names that should never be overwritten
221
+ * @returns Merged record data
222
+ * @private
223
+ */
224
+ private mergeRecords;
225
+ /**
226
+ * Create a backup of a file before updating
227
+ *
228
+ * Creates a timestamped backup copy of the file in a backup directory
229
+ * with the original filename, timestamp suffix, and .backup extension.
230
+ * The backup directory defaults to .backups but can be configured.
231
+ *
232
+ * @param filePath - Path to the file to backup
233
+ * @param backupDirName - Name of the backup directory (optional)
234
+ * @returns Promise that resolves when backup is created
235
+ * @private
236
+ */
237
+ private createBackup;
153
238
  }