@memberjunction/metadata-sync 2.55.0 → 2.56.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.
Files changed (64) hide show
  1. package/README.md +92 -51
  2. package/dist/index.d.ts +21 -1
  3. package/dist/index.js +41 -3
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/file-backup-manager.js +2 -2
  6. package/dist/lib/file-backup-manager.js.map +1 -1
  7. package/dist/lib/sql-logger.d.ts +44 -0
  8. package/dist/lib/sql-logger.js +140 -0
  9. package/dist/lib/sql-logger.js.map +1 -0
  10. package/dist/lib/sync-engine.js +2 -2
  11. package/dist/lib/sync-engine.js.map +1 -1
  12. package/dist/lib/transaction-manager.d.ts +36 -0
  13. package/dist/lib/transaction-manager.js +117 -0
  14. package/dist/lib/transaction-manager.js.map +1 -0
  15. package/dist/services/FileResetService.d.ts +30 -0
  16. package/dist/services/FileResetService.js +182 -0
  17. package/dist/services/FileResetService.js.map +1 -0
  18. package/dist/services/InitService.d.ts +17 -0
  19. package/dist/services/InitService.js +118 -0
  20. package/dist/services/InitService.js.map +1 -0
  21. package/dist/services/PullService.d.ts +45 -0
  22. package/dist/services/PullService.js +564 -0
  23. package/dist/services/PullService.js.map +1 -0
  24. package/dist/services/PushService.d.ts +45 -0
  25. package/dist/services/PushService.js +394 -0
  26. package/dist/services/PushService.js.map +1 -0
  27. package/dist/services/StatusService.d.ts +32 -0
  28. package/dist/services/StatusService.js +138 -0
  29. package/dist/services/StatusService.js.map +1 -0
  30. package/dist/services/WatchService.d.ts +32 -0
  31. package/dist/services/WatchService.js +242 -0
  32. package/dist/services/WatchService.js.map +1 -0
  33. package/dist/services/index.d.ts +16 -0
  34. package/dist/services/index.js +28 -0
  35. package/dist/services/index.js.map +1 -0
  36. package/package.json +14 -45
  37. package/bin/debug.js +0 -7
  38. package/bin/run +0 -17
  39. package/bin/run.js +0 -6
  40. package/dist/commands/file-reset/index.d.ts +0 -15
  41. package/dist/commands/file-reset/index.js +0 -221
  42. package/dist/commands/file-reset/index.js.map +0 -1
  43. package/dist/commands/init/index.d.ts +0 -7
  44. package/dist/commands/init/index.js +0 -155
  45. package/dist/commands/init/index.js.map +0 -1
  46. package/dist/commands/pull/index.d.ts +0 -246
  47. package/dist/commands/pull/index.js +0 -1448
  48. package/dist/commands/pull/index.js.map +0 -1
  49. package/dist/commands/push/index.d.ts +0 -41
  50. package/dist/commands/push/index.js +0 -1129
  51. package/dist/commands/push/index.js.map +0 -1
  52. package/dist/commands/status/index.d.ts +0 -10
  53. package/dist/commands/status/index.js +0 -199
  54. package/dist/commands/status/index.js.map +0 -1
  55. package/dist/commands/validate/index.d.ts +0 -15
  56. package/dist/commands/validate/index.js +0 -149
  57. package/dist/commands/validate/index.js.map +0 -1
  58. package/dist/commands/watch/index.d.ts +0 -15
  59. package/dist/commands/watch/index.js +0 -300
  60. package/dist/commands/watch/index.js.map +0 -1
  61. package/dist/hooks/init.d.ts +0 -3
  62. package/dist/hooks/init.js +0 -59
  63. package/dist/hooks/init.js.map +0 -1
  64. package/oclif.manifest.json +0 -376
@@ -1,1448 +0,0 @@
1
- "use strict";
2
- /**
3
- * @fileoverview Pull command implementation for MetadataSync
4
- * @module commands/pull
5
- *
6
- * This module implements the pull command which retrieves metadata records from
7
- * the MemberJunction database and saves them as local JSON files. It supports:
8
- * - Filtering records with SQL expressions
9
- * - Pulling related entities with foreign key relationships
10
- * - Externalizing large text fields to separate files
11
- * - Creating multi-record JSON files
12
- * - Recursive directory search for entity configurations
13
- */
14
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
- if (k2 === undefined) k2 = k;
16
- var desc = Object.getOwnPropertyDescriptor(m, k);
17
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
- desc = { enumerable: true, get: function() { return m[k]; } };
19
- }
20
- Object.defineProperty(o, k2, desc);
21
- }) : (function(o, m, k, k2) {
22
- if (k2 === undefined) k2 = k;
23
- o[k2] = m[k];
24
- }));
25
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
- Object.defineProperty(o, "default", { enumerable: true, value: v });
27
- }) : function(o, v) {
28
- o["default"] = v;
29
- });
30
- var __importStar = (this && this.__importStar) || function (mod) {
31
- if (mod && mod.__esModule) return mod;
32
- var result = {};
33
- if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
34
- __setModuleDefault(result, mod);
35
- return result;
36
- };
37
- var __importDefault = (this && this.__importDefault) || function (mod) {
38
- return (mod && mod.__esModule) ? mod : { "default": mod };
39
- };
40
- Object.defineProperty(exports, "__esModule", { value: true });
41
- const core_1 = require("@oclif/core");
42
- const fs_extra_1 = __importDefault(require("fs-extra"));
43
- const path_1 = __importDefault(require("path"));
44
- const prompts_1 = require("@inquirer/prompts");
45
- const ora_classic_1 = __importDefault(require("ora-classic"));
46
- const config_1 = require("../../config");
47
- const core_2 = require("@memberjunction/core");
48
- const provider_utils_1 = require("../../lib/provider-utils");
49
- const config_manager_1 = require("../../lib/config-manager");
50
- const singleton_manager_1 = require("../../lib/singleton-manager");
51
- const chalk_1 = __importDefault(require("chalk"));
52
- /**
53
- * Pull metadata records from database to local files
54
- *
55
- * @class Pull
56
- * @extends Command
57
- *
58
- * @example
59
- * ```bash
60
- * # Pull all records for an entity
61
- * mj-sync pull --entity="AI Prompts"
62
- *
63
- * # Pull with filter
64
- * mj-sync pull --entity="AI Prompts" --filter="CategoryID='123'"
65
- *
66
- * # Pull to multi-record file
67
- * mj-sync pull --entity="AI Prompts" --multi-file="all-prompts.json"
68
- * ```
69
- */
70
- class Pull extends core_1.Command {
71
- static description = 'Pull metadata from database to local files';
72
- static examples = [
73
- `<%= config.bin %> <%= command.id %> --entity="AI Prompts"`,
74
- `<%= config.bin %> <%= command.id %> --entity="AI Prompts" --filter="CategoryID='customer-service-id'"`,
75
- ];
76
- static flags = {
77
- entity: core_1.Flags.string({ description: 'Entity name to pull', required: true }),
78
- filter: core_1.Flags.string({ description: 'Additional filter for pulling specific records' }),
79
- 'dry-run': core_1.Flags.boolean({ description: 'Show what would be pulled without actually pulling' }),
80
- 'multi-file': core_1.Flags.string({ description: 'Create a single file with multiple records (provide filename)' }),
81
- verbose: core_1.Flags.boolean({ char: 'v', description: 'Show detailed output' }),
82
- 'no-validate': core_1.Flags.boolean({ description: 'Skip validation before pull' }),
83
- };
84
- async run() {
85
- const { flags } = await this.parse(Pull);
86
- const spinner = (0, ora_classic_1.default)();
87
- try {
88
- // Load MJ config first (before changing directory)
89
- spinner.start('Loading configuration');
90
- const mjConfig = (0, config_1.loadMJConfig)();
91
- if (!mjConfig) {
92
- this.error('No mj.config.cjs found in current directory or parent directories');
93
- }
94
- // Stop spinner before provider initialization (which logs to console)
95
- spinner.stop();
96
- // Initialize data provider
97
- const provider = await (0, provider_utils_1.initializeProvider)(mjConfig);
98
- // Get singleton sync engine
99
- const syncEngine = await (0, singleton_manager_1.getSyncEngine)((0, provider_utils_1.getSystemUser)());
100
- // Show success after all initialization is complete
101
- if (flags.verbose) {
102
- spinner.succeed('Configuration and metadata loaded');
103
- }
104
- else {
105
- spinner.stop();
106
- }
107
- // Run validation unless disabled
108
- if (!flags['no-validate']) {
109
- const { ValidationService } = await Promise.resolve().then(() => __importStar(require('../../services/ValidationService')));
110
- const { FormattingService } = await Promise.resolve().then(() => __importStar(require('../../services/FormattingService')));
111
- spinner.start('Validating metadata...');
112
- const validator = new ValidationService({ verbose: flags.verbose });
113
- const formatter = new FormattingService();
114
- const validationResult = await validator.validateDirectory(config_manager_1.configManager.getOriginalCwd());
115
- spinner.stop();
116
- if (!validationResult.isValid || validationResult.warnings.length > 0) {
117
- // Show validation results
118
- this.log('\n' + formatter.formatValidationResult(validationResult, flags.verbose));
119
- if (!validationResult.isValid) {
120
- // Ask for confirmation
121
- const shouldContinue = await (0, prompts_1.select)({
122
- message: 'Validation failed with errors. Do you want to continue anyway?',
123
- choices: [
124
- { name: 'No, fix the errors first', value: false },
125
- { name: 'Yes, continue anyway', value: true }
126
- ],
127
- default: false
128
- });
129
- if (!shouldContinue) {
130
- this.error('Pull cancelled due to validation errors.');
131
- }
132
- }
133
- }
134
- else {
135
- this.log(chalk_1.default.green('✓ Validation passed'));
136
- }
137
- }
138
- let targetDir;
139
- let entityConfig;
140
- // Check if we should use a specific target directory
141
- const envTargetDir = process.env.METADATA_SYNC_TARGET_DIR;
142
- if (envTargetDir) {
143
- if (flags.verbose) {
144
- console.log(`Using specified target directory: ${envTargetDir}`);
145
- }
146
- process.chdir(envTargetDir);
147
- targetDir = process.cwd();
148
- // Load entity config from the current directory
149
- entityConfig = await (0, config_1.loadEntityConfig)(targetDir);
150
- if (!entityConfig) {
151
- this.error(`No .mj-sync.json found in ${targetDir}`);
152
- }
153
- if (entityConfig.entity !== flags.entity) {
154
- this.error(`Directory ${targetDir} is configured for entity "${entityConfig.entity}", not "${flags.entity}"`);
155
- }
156
- }
157
- else {
158
- // Original behavior - find entity directory
159
- const entityDirs = await this.findEntityDirectories(flags.entity);
160
- if (entityDirs.length === 0) {
161
- this.error(`No directory found for entity "${flags.entity}". Run "mj-sync init" first.`);
162
- }
163
- if (entityDirs.length === 1) {
164
- targetDir = entityDirs[0];
165
- }
166
- else {
167
- // Multiple directories found, ask user
168
- targetDir = await (0, prompts_1.select)({
169
- message: `Multiple directories found for entity "${flags.entity}". Which one to use?`,
170
- choices: entityDirs.map(dir => ({ name: dir, value: dir }))
171
- });
172
- }
173
- entityConfig = await (0, config_1.loadEntityConfig)(targetDir);
174
- if (!entityConfig) {
175
- this.error(`Invalid entity configuration in ${targetDir}`);
176
- }
177
- }
178
- // Show configuration notice only if relevant and in verbose mode
179
- if (flags.verbose && entityConfig.pull?.appendRecordsToExistingFile && entityConfig.pull?.newFileName) {
180
- const targetFile = path_1.default.join(targetDir, entityConfig.pull.newFileName.endsWith('.json')
181
- ? entityConfig.pull.newFileName
182
- : `${entityConfig.pull.newFileName}.json`);
183
- if (await fs_extra_1.default.pathExists(targetFile)) {
184
- // File exists - inform about append behavior
185
- this.log(`\n📝 Configuration: New records will be appended to existing file '${path_1.default.basename(targetFile)}'`);
186
- }
187
- // If file doesn't exist, no need to mention anything special - we're just creating it
188
- }
189
- // Pull records
190
- spinner.start(`Pulling ${flags.entity} records`);
191
- const rv = new core_2.RunView();
192
- let filter = '';
193
- if (flags.filter) {
194
- filter = flags.filter;
195
- }
196
- else if (entityConfig.pull?.filter) {
197
- filter = entityConfig.pull.filter;
198
- }
199
- const result = await rv.RunView({
200
- EntityName: flags.entity,
201
- ExtraFilter: filter,
202
- ResultType: 'entity_object'
203
- }, (0, provider_utils_1.getSystemUser)());
204
- if (!result.Success) {
205
- this.error(`Failed to pull records: ${result.ErrorMessage}`);
206
- }
207
- spinner.succeed(`Found ${result.Results.length} records`);
208
- if (flags['dry-run']) {
209
- this.log(`\nDry run mode - would pull ${result.Results.length} records to ${targetDir}`);
210
- return;
211
- }
212
- // Check if we need to wait for async property loading
213
- if (entityConfig.pull?.externalizeFields && result.Results.length > 0) {
214
- const metadata = new core_2.Metadata();
215
- const entityInfo = metadata.EntityByName(flags.entity);
216
- if (entityInfo) {
217
- const externalizeConfig = entityConfig.pull.externalizeFields;
218
- let fieldsToExternalize = [];
219
- if (Array.isArray(externalizeConfig)) {
220
- if (externalizeConfig.length > 0 && typeof externalizeConfig[0] === 'string') {
221
- // Simple string array
222
- fieldsToExternalize = externalizeConfig;
223
- }
224
- else {
225
- // New pattern format
226
- fieldsToExternalize = externalizeConfig
227
- .map(item => item.field);
228
- }
229
- }
230
- else {
231
- // Object format
232
- fieldsToExternalize = Object.keys(externalizeConfig);
233
- }
234
- // Get all field names from entity metadata
235
- const metadataFieldNames = entityInfo.Fields.map(f => f.Name);
236
- // Check if any externalized fields are NOT in metadata (likely computed properties)
237
- const computedFields = fieldsToExternalize.filter(f => !metadataFieldNames.includes(f));
238
- if (computedFields.length > 0) {
239
- if (flags.verbose) {
240
- spinner.start(`Waiting 5 seconds for async property loading in ${flags.entity} (${computedFields.join(', ')})...`);
241
- }
242
- await new Promise(resolve => setTimeout(resolve, 5000));
243
- if (flags.verbose) {
244
- spinner.succeed('Async property loading wait complete');
245
- }
246
- else {
247
- spinner.stop();
248
- }
249
- }
250
- }
251
- }
252
- // Process each record
253
- const entityInfo = syncEngine.getEntityInfo(flags.entity);
254
- if (!entityInfo) {
255
- this.error(`Entity information not found for: ${flags.entity}`);
256
- }
257
- spinner.start('Processing records');
258
- let processed = 0;
259
- let updated = 0;
260
- let created = 0;
261
- let skipped = 0;
262
- // If multi-file flag is set, collect all records
263
- if (flags['multi-file']) {
264
- const allRecords = [];
265
- for (const record of result.Results) {
266
- try {
267
- // Build primary key
268
- const primaryKey = {};
269
- for (const pk of entityInfo.PrimaryKeys) {
270
- primaryKey[pk.Name] = record[pk.Name];
271
- }
272
- // Process record for multi-file
273
- const recordData = await this.processRecordData(record, primaryKey, targetDir, entityConfig, syncEngine, flags, true);
274
- allRecords.push(recordData);
275
- processed++;
276
- if (flags.verbose) {
277
- spinner.text = `Processing records (${processed}/${result.Results.length})`;
278
- }
279
- }
280
- catch (error) {
281
- this.warn(`Failed to process record: ${error.message || error}`);
282
- }
283
- }
284
- // Write all records to single file
285
- if (allRecords.length > 0) {
286
- const fileName = flags['multi-file'].endsWith('.json') ? flags['multi-file'] : `${flags['multi-file']}.json`;
287
- const filePath = path_1.default.join(targetDir, fileName);
288
- await fs_extra_1.default.writeJson(filePath, allRecords, { spaces: 2 });
289
- spinner.succeed(`Pulled ${processed} records to ${path_1.default.basename(filePath)}`);
290
- }
291
- }
292
- else {
293
- // Smart update logic for single-file-per-record
294
- if (flags.verbose) {
295
- spinner.text = 'Scanning for existing files...';
296
- }
297
- // Find existing files
298
- const filePattern = entityConfig.pull?.filePattern || entityConfig.filePattern || '*.json';
299
- const existingFiles = await this.findExistingFiles(targetDir, filePattern);
300
- if (flags.verbose) {
301
- this.log(`Found ${existingFiles.length} existing files matching pattern '${filePattern}'`);
302
- existingFiles.forEach(f => this.log(` - ${path_1.default.basename(f)}`));
303
- }
304
- // Load existing records and build lookup map
305
- const existingRecordsMap = await this.loadExistingRecords(existingFiles, entityInfo);
306
- if (flags.verbose) {
307
- this.log(`Loaded ${existingRecordsMap.size} existing records from files`);
308
- }
309
- // Separate records into new and existing
310
- const newRecords = [];
311
- const existingRecordsToUpdate = [];
312
- for (const record of result.Results) {
313
- // Build primary key
314
- const primaryKey = {};
315
- for (const pk of entityInfo.PrimaryKeys) {
316
- primaryKey[pk.Name] = record[pk.Name];
317
- }
318
- // Create lookup key
319
- const lookupKey = this.createPrimaryKeyLookup(primaryKey);
320
- const existingFileInfo = existingRecordsMap.get(lookupKey);
321
- if (existingFileInfo) {
322
- // Record exists locally
323
- if (entityConfig.pull?.updateExistingRecords !== false) {
324
- existingRecordsToUpdate.push({ record, primaryKey, filePath: existingFileInfo.filePath });
325
- }
326
- else {
327
- skipped++;
328
- if (flags.verbose) {
329
- this.log(`Skipping existing record: ${lookupKey}`);
330
- }
331
- }
332
- }
333
- else {
334
- // Record doesn't exist locally
335
- if (entityConfig.pull?.createNewFileIfNotFound !== false) {
336
- newRecords.push({ record, primaryKey });
337
- }
338
- else {
339
- skipped++;
340
- if (flags.verbose) {
341
- this.log(`Skipping new record (createNewFileIfNotFound=false): ${lookupKey}`);
342
- }
343
- }
344
- }
345
- }
346
- // Track which files have been backed up to avoid duplicates
347
- const backedUpFiles = new Set();
348
- // Process existing records updates
349
- for (const { record, primaryKey, filePath } of existingRecordsToUpdate) {
350
- try {
351
- spinner.text = `Updating existing records (${updated + 1}/${existingRecordsToUpdate.length})`;
352
- // Create backup if configured (only once per file)
353
- if (entityConfig.pull?.backupBeforeUpdate && !backedUpFiles.has(filePath)) {
354
- await this.createBackup(filePath, entityConfig.pull?.backupDirectory);
355
- backedUpFiles.add(filePath);
356
- }
357
- // Load existing file data
358
- const existingData = await fs_extra_1.default.readJson(filePath);
359
- // Find the specific existing record that matches this primary key
360
- let existingRecordData;
361
- if (Array.isArray(existingData)) {
362
- // Find the matching record in the array
363
- const matchingRecord = existingData.find(r => this.createPrimaryKeyLookup(r.primaryKey || {}) === this.createPrimaryKeyLookup(primaryKey));
364
- existingRecordData = matchingRecord || existingData[0]; // Fallback to first if not found
365
- }
366
- else {
367
- existingRecordData = existingData;
368
- }
369
- // Process the new record data (isNewRecord = false for updates)
370
- const newRecordData = await this.processRecordData(record, primaryKey, targetDir, entityConfig, syncEngine, flags, false, existingRecordData);
371
- // Apply merge strategy
372
- const mergedData = await this.mergeRecords(existingRecordData, newRecordData, entityConfig.pull?.mergeStrategy || 'merge', entityConfig.pull?.preserveFields || []);
373
- // Write updated data
374
- if (Array.isArray(existingData)) {
375
- // Update the record in the array
376
- const index = existingData.findIndex(r => this.createPrimaryKeyLookup(r.primaryKey || {}) === this.createPrimaryKeyLookup(primaryKey));
377
- if (index >= 0) {
378
- existingData[index] = mergedData;
379
- await fs_extra_1.default.writeJson(filePath, existingData, { spaces: 2 });
380
- }
381
- }
382
- else {
383
- await fs_extra_1.default.writeJson(filePath, mergedData, { spaces: 2 });
384
- }
385
- updated++;
386
- processed++;
387
- if (flags.verbose) {
388
- this.log(`Updated: ${filePath}`);
389
- }
390
- }
391
- catch (error) {
392
- this.warn(`Failed to update record: ${error.message || error}`);
393
- }
394
- }
395
- // Process new records
396
- if (newRecords.length > 0) {
397
- spinner.text = `Creating new records (0/${newRecords.length})`;
398
- if (entityConfig.pull?.appendRecordsToExistingFile && entityConfig.pull?.newFileName) {
399
- // Append all new records to a single file
400
- const fileName = entityConfig.pull.newFileName.endsWith('.json')
401
- ? entityConfig.pull.newFileName
402
- : `${entityConfig.pull.newFileName}.json`;
403
- const filePath = path_1.default.join(targetDir, fileName);
404
- // Load existing file if it exists
405
- let existingData = [];
406
- if (await fs_extra_1.default.pathExists(filePath)) {
407
- const fileData = await fs_extra_1.default.readJson(filePath);
408
- existingData = Array.isArray(fileData) ? fileData : [fileData];
409
- }
410
- // Process and append all new records
411
- for (const { record, primaryKey } of newRecords) {
412
- try {
413
- // For new records, pass isNewRecord = true (default)
414
- const recordData = await this.processRecordData(record, primaryKey, targetDir, entityConfig, syncEngine, flags, true);
415
- existingData.push(recordData);
416
- created++;
417
- processed++;
418
- if (flags.verbose) {
419
- spinner.text = `Creating new records (${created}/${newRecords.length})`;
420
- }
421
- }
422
- catch (error) {
423
- this.warn(`Failed to process new record: ${error.message || error}`);
424
- }
425
- }
426
- // Write the combined data
427
- await fs_extra_1.default.writeJson(filePath, existingData, { spaces: 2 });
428
- if (flags.verbose) {
429
- this.log(`Appended ${created} new records to: ${filePath}`);
430
- }
431
- }
432
- else {
433
- // Create individual files for each new record
434
- for (const { record, primaryKey } of newRecords) {
435
- try {
436
- await this.processRecord(record, primaryKey, targetDir, entityConfig, syncEngine, flags);
437
- created++;
438
- processed++;
439
- if (flags.verbose) {
440
- spinner.text = `Creating new records (${created}/${newRecords.length})`;
441
- }
442
- }
443
- catch (error) {
444
- this.warn(`Failed to process new record: ${error.message || error}`);
445
- }
446
- }
447
- }
448
- }
449
- // Final status
450
- const statusParts = [`Processed ${processed} records`];
451
- if (updated > 0)
452
- statusParts.push(`updated ${updated}`);
453
- if (created > 0)
454
- statusParts.push(`created ${created}`);
455
- if (skipped > 0)
456
- statusParts.push(`skipped ${skipped}`);
457
- spinner.succeed(statusParts.join(', '));
458
- }
459
- }
460
- catch (error) {
461
- spinner.fail('Pull failed');
462
- // Enhanced error logging for debugging
463
- this.log('\n=== Pull Error Details ===');
464
- this.log(`Error type: ${error?.constructor?.name || 'Unknown'}`);
465
- this.log(`Error message: ${error instanceof Error ? error.message : String(error)}`);
466
- if (error instanceof Error && error.stack) {
467
- this.log(`\nStack trace:`);
468
- this.log(error.stack);
469
- }
470
- // Log context information
471
- this.log(`\nContext:`);
472
- this.log(`- Working directory: ${config_manager_1.configManager.getOriginalCwd()}`);
473
- this.log(`- Entity: ${flags.entity || 'not specified'}`);
474
- this.log(`- Filter: ${flags.filter || 'none'}`);
475
- this.log(`- Flags: ${JSON.stringify(flags, null, 2)}`);
476
- // Check if error is related to common issues
477
- const errorMessage = error instanceof Error ? error.message : String(error);
478
- if (errorMessage.includes('No directory found for entity')) {
479
- this.log(`\nHint: This appears to be an entity directory configuration issue.`);
480
- this.log(`Run "mj-sync init" to create directories or ensure .mj-sync.json files exist.`);
481
- }
482
- else if (errorMessage.includes('database') || errorMessage.includes('connection')) {
483
- this.log(`\nHint: This appears to be a database connectivity issue.`);
484
- this.log(`Check your mj.config.cjs configuration and database connectivity.`);
485
- }
486
- else if (errorMessage.includes('Failed to pull records')) {
487
- this.log(`\nHint: This appears to be a database query issue.`);
488
- this.log(`Check if the entity name "${flags.entity}" is correct and exists in the database.`);
489
- }
490
- else if (errorMessage.includes('Entity information not found')) {
491
- this.log(`\nHint: The entity "${flags.entity}" was not found in metadata.`);
492
- this.log(`Check the entity name spelling and ensure it exists in the database.`);
493
- }
494
- this.error(error);
495
- }
496
- finally {
497
- // Reset singletons
498
- (0, singleton_manager_1.resetSyncEngine)();
499
- // Exit process to prevent background MJ tasks from throwing errors
500
- // We don't explicitly close the connection - let the process termination handle it
501
- process.exit(0);
502
- }
503
- }
504
- /**
505
- * Find directories containing configuration for the specified entity
506
- *
507
- * Recursively searches the current working directory for .mj-sync.json files
508
- * that specify the given entity name. Returns all matching directories to
509
- * allow user selection when multiple locations exist.
510
- *
511
- * @param entityName - Name of the entity to search for
512
- * @returns Promise resolving to array of directory paths
513
- * @private
514
- */
515
- async findEntityDirectories(entityName) {
516
- const dirs = [];
517
- // Search for directories with matching entity config
518
- const searchDirs = async (dir) => {
519
- const entries = await fs_extra_1.default.readdir(dir, { withFileTypes: true });
520
- for (const entry of entries) {
521
- if (entry.isDirectory()) {
522
- const fullPath = path_1.default.join(dir, entry.name);
523
- const config = await (0, config_1.loadEntityConfig)(fullPath);
524
- if (config && config.entity === entityName) {
525
- dirs.push(fullPath);
526
- }
527
- else {
528
- // Recurse
529
- await searchDirs(fullPath);
530
- }
531
- }
532
- }
533
- };
534
- await searchDirs(config_manager_1.configManager.getOriginalCwd());
535
- return dirs;
536
- }
537
- /**
538
- * Process a single record and save to file
539
- *
540
- * Converts a database record into the file format and writes it to disk.
541
- * This is a wrapper around processRecordData that handles file writing.
542
- *
543
- * @param record - Raw database record
544
- * @param primaryKey - Primary key fields and values
545
- * @param targetDir - Directory to save the file
546
- * @param entityConfig - Entity configuration with pull settings
547
- * @param syncEngine - Sync engine instance
548
- * @returns Promise that resolves when file is written
549
- * @private
550
- */
551
- async processRecord(record, primaryKey, targetDir, entityConfig, syncEngine, flags) {
552
- const recordData = await this.processRecordData(record, primaryKey, targetDir, entityConfig, syncEngine, flags, true);
553
- // Determine file path
554
- const fileName = this.buildFileName(primaryKey, entityConfig);
555
- const filePath = path_1.default.join(targetDir, fileName);
556
- // Write JSON file
557
- await fs_extra_1.default.writeJson(filePath, recordData, { spaces: 2 });
558
- }
559
- /**
560
- * Process record data for storage
561
- *
562
- * Transforms a raw database record into the RecordData format used for file storage.
563
- * Handles field externalization, related entity pulling, and checksum calculation.
564
- *
565
- * @param record - Raw database record
566
- * @param primaryKey - Primary key fields and values
567
- * @param targetDir - Directory where files will be saved
568
- * @param entityConfig - Entity configuration with defaults and settings
569
- * @param syncEngine - Sync engine for checksum calculation
570
- * @param flags - Command flags
571
- * @param isNewRecord - Whether this is a new record
572
- * @param existingRecordData - Existing record data to preserve field selection
573
- * @param currentDepth - Current recursion depth for recursive entities
574
- * @param ancestryPath - Set of IDs in current ancestry chain to prevent circular references
575
- * @returns Promise resolving to formatted RecordData
576
- * @private
577
- */
578
- async processRecordData(record, primaryKey, targetDir, entityConfig, syncEngine, flags, isNewRecord = true, existingRecordData, currentDepth = 0, ancestryPath = new Set()) {
579
- // Build record data - we'll restructure at the end for proper ordering
580
- const fields = {};
581
- const relatedEntities = {};
582
- // Debug: Log all fields in first record (only in verbose mode)
583
- if (flags?.verbose) {
584
- const recordKeys = Object.keys(record);
585
- console.log('\n=== DEBUG: Processing record ===');
586
- console.log('Entity:', entityConfig.entity);
587
- console.log('Total fields:', recordKeys.length);
588
- console.log('Field names:', recordKeys.filter(k => !k.startsWith('__mj_')).join(', '));
589
- console.log('Has TemplateText?:', recordKeys.includes('TemplateText'));
590
- console.log('externalizeFields config:', entityConfig.pull?.externalizeFields);
591
- }
592
- // Get the underlying data from the entity object
593
- // If it's an entity object, it will have a GetAll() method
594
- let dataToProcess = record;
595
- if (typeof record.GetAll === 'function') {
596
- // It's an entity object, get the underlying data
597
- dataToProcess = record.GetAll();
598
- }
599
- // Get externalize configuration for pattern lookup
600
- const externalizeConfig = entityConfig.pull?.externalizeFields;
601
- let externalizeMap = new Map();
602
- if (externalizeConfig) {
603
- if (Array.isArray(externalizeConfig)) {
604
- if (externalizeConfig.length > 0 && typeof externalizeConfig[0] === 'string') {
605
- // Simple string array
606
- externalizeConfig.forEach(f => externalizeMap.set(f, undefined));
607
- }
608
- else {
609
- // New pattern format
610
- externalizeConfig.forEach(item => externalizeMap.set(item.field, item.pattern));
611
- }
612
- }
613
- else {
614
- // Object format
615
- Object.keys(externalizeConfig).forEach(f => externalizeMap.set(f, undefined));
616
- }
617
- }
618
- // Process regular fields from the underlying data
619
- for (const [fieldName, fieldValue] of Object.entries(dataToProcess)) {
620
- // Skip primary key fields
621
- if (primaryKey[fieldName] !== undefined) {
622
- continue;
623
- }
624
- // Skip internal fields
625
- if (fieldName.startsWith('__mj_')) {
626
- continue;
627
- }
628
- // Skip excluded fields
629
- if (entityConfig.pull?.excludeFields?.includes(fieldName)) {
630
- continue;
631
- }
632
- // Skip fields already externalized
633
- if (fields[fieldName]) {
634
- continue;
635
- }
636
- // Skip virtual/computed fields - check entity metadata
637
- const metadata = new core_2.Metadata();
638
- const entityInfo = metadata.EntityByName(entityConfig.entity);
639
- if (entityInfo) {
640
- const fieldInfo = entityInfo.Fields.find(f => f.Name === fieldName);
641
- if (fieldInfo && !fieldInfo.IsVirtual) {
642
- // Field exists in metadata and is not virtual, keep it
643
- }
644
- else if (fieldInfo && fieldInfo.IsVirtual) {
645
- // Skip virtual fields
646
- continue;
647
- }
648
- else if (!fieldInfo) {
649
- // Field not in metadata at all
650
- // Check if it's explicitly configured for externalization, lookup, or exclusion
651
- const isConfiguredField = entityConfig.pull?.externalizeFields?.includes(fieldName) ||
652
- entityConfig.pull?.lookupFields?.[fieldName] ||
653
- entityConfig.pull?.excludeFields?.includes(fieldName);
654
- if (!isConfiguredField) {
655
- // Skip fields not in metadata and not explicitly configured
656
- continue;
657
- }
658
- // Otherwise, allow the field to be processed since it's explicitly configured
659
- }
660
- }
661
- // Check if this field should be converted to a lookup
662
- const lookupConfig = entityConfig.pull?.lookupFields?.[fieldName];
663
- if (lookupConfig && fieldValue) {
664
- // Convert foreign key to @lookup reference
665
- const lookupValue = await this.convertToLookup(fieldValue, lookupConfig.entity, lookupConfig.field, syncEngine);
666
- if (lookupValue) {
667
- fields[fieldName] = lookupValue;
668
- continue;
669
- }
670
- }
671
- // Check if this is an external file field
672
- if (await this.shouldExternalizeField(fieldName, fieldValue, entityConfig)) {
673
- // Check if this field is preserved and already has a @file: reference
674
- const isPreservedField = entityConfig.pull?.preserveFields?.includes(fieldName);
675
- const existingFieldValue = existingRecordData?.fields?.[fieldName];
676
- if (isPreservedField && existingFieldValue && typeof existingFieldValue === 'string' && existingFieldValue.startsWith('@file:')) {
677
- // Field is preserved and has existing @file: reference - update the existing file
678
- const existingFilePath = existingFieldValue.replace('@file:', '');
679
- const fullPath = path_1.default.join(targetDir, existingFilePath);
680
- // Ensure directory exists
681
- await fs_extra_1.default.ensureDir(path_1.default.dirname(fullPath));
682
- // Write the content to the existing file path
683
- await fs_extra_1.default.writeFile(fullPath, String(fieldValue), 'utf-8');
684
- // Keep the existing @file: reference
685
- fields[fieldName] = existingFieldValue;
686
- }
687
- else {
688
- // Normal externalization - create new file
689
- const pattern = externalizeMap.get(fieldName);
690
- const fileName = await this.createExternalFile(targetDir, record, primaryKey, fieldName, String(fieldValue), entityConfig, pattern);
691
- fields[fieldName] = fileName; // fileName already includes @file: prefix if pattern-based
692
- }
693
- }
694
- else {
695
- fields[fieldName] = fieldValue;
696
- }
697
- }
698
- // Now check for externalized fields that might be computed properties
699
- // We process ALL externalized fields, including those not in the data
700
- if (entityConfig.pull?.externalizeFields && typeof record.GetAll === 'function') {
701
- const externalizeConfig = entityConfig.pull.externalizeFields;
702
- // Normalize configuration to array format
703
- let externalizeItems = [];
704
- if (Array.isArray(externalizeConfig)) {
705
- if (externalizeConfig.length > 0 && typeof externalizeConfig[0] === 'string') {
706
- // Simple string array
707
- externalizeItems = externalizeConfig.map(f => ({ field: f }));
708
- }
709
- else {
710
- // Already in the new format
711
- externalizeItems = externalizeConfig;
712
- }
713
- }
714
- else {
715
- // Object format
716
- externalizeItems = Object.entries(externalizeConfig).map(([field, config]) => ({
717
- field,
718
- pattern: undefined // Will use default pattern
719
- }));
720
- }
721
- // Get the keys from the underlying data to identify computed properties
722
- const dataKeys = Object.keys(dataToProcess);
723
- for (const externalItem of externalizeItems) {
724
- const externalField = externalItem.field;
725
- // Only process fields that are NOT in the underlying data
726
- // (these are likely computed properties)
727
- if (dataKeys.includes(externalField)) {
728
- continue; // This was already processed in the main loop
729
- }
730
- try {
731
- // Use bracket notation to access properties (including getters)
732
- const fieldValue = record[externalField];
733
- if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
734
- if (await this.shouldExternalizeField(externalField, fieldValue, entityConfig)) {
735
- // Check if this field is preserved and already has a @file: reference
736
- const isPreservedField = entityConfig.pull?.preserveFields?.includes(externalField);
737
- const existingFieldValue = existingRecordData?.fields?.[externalField];
738
- if (isPreservedField && existingFieldValue && typeof existingFieldValue === 'string' && existingFieldValue.startsWith('@file:')) {
739
- // Field is preserved and has existing @file: reference - update the existing file
740
- const existingFilePath = existingFieldValue.replace('@file:', '');
741
- const fullPath = path_1.default.join(targetDir, existingFilePath);
742
- // Ensure directory exists
743
- await fs_extra_1.default.ensureDir(path_1.default.dirname(fullPath));
744
- // Write the content to the existing file path
745
- await fs_extra_1.default.writeFile(fullPath, String(fieldValue), 'utf-8');
746
- // Keep the existing @file: reference
747
- fields[externalField] = existingFieldValue;
748
- }
749
- else {
750
- // Normal externalization - create new file
751
- const fileName = await this.createExternalFile(targetDir, record, primaryKey, externalField, String(fieldValue), entityConfig, externalItem.pattern);
752
- fields[externalField] = fileName; // fileName already includes @file: prefix if pattern-based
753
- }
754
- }
755
- else {
756
- // Include the field value if not externalized
757
- fields[externalField] = fieldValue;
758
- }
759
- }
760
- }
761
- catch (error) {
762
- // Property might not exist, that's okay
763
- if (flags?.verbose) {
764
- console.log(`Could not get property ${externalField}: ${error}`);
765
- }
766
- }
767
- }
768
- }
769
- // Pull related entities if configured
770
- if (entityConfig.pull?.relatedEntities) {
771
- const related = await this.pullRelatedEntities(record, entityConfig.pull.relatedEntities, syncEngine, entityConfig, flags, currentDepth, ancestryPath);
772
- Object.assign(relatedEntities, related);
773
- }
774
- // Get entity metadata to check defaults
775
- const metadata = new core_2.Metadata();
776
- const entityInfo = metadata.EntityByName(entityConfig.entity);
777
- // Filter out null values and fields matching their defaults
778
- const cleanedFields = {};
779
- // Get the set of fields that existed in the original record (if updating)
780
- const existingFieldNames = existingRecordData?.fields ? new Set(Object.keys(existingRecordData.fields)) : new Set();
781
- for (const [fieldName, fieldValue] of Object.entries(fields)) {
782
- let includeField = false;
783
- if (!isNewRecord && existingFieldNames.has(fieldName)) {
784
- // For updates: Always preserve fields that existed in the original record
785
- includeField = true;
786
- }
787
- else {
788
- // For new records or new fields in existing records:
789
- // Skip null/undefined/empty string values
790
- if (fieldValue === null || fieldValue === undefined || fieldValue === '') {
791
- includeField = false;
792
- }
793
- else if (entityInfo) {
794
- // Check if value matches the field's default
795
- const fieldInfo = entityInfo.Fields.find(f => f.Name === fieldName);
796
- if (fieldInfo && fieldInfo.DefaultValue !== null && fieldInfo.DefaultValue !== undefined) {
797
- // Compare with default value
798
- if (fieldValue === fieldInfo.DefaultValue) {
799
- includeField = false;
800
- }
801
- // Special handling for boolean defaults (might be stored as strings)
802
- else if (typeof fieldValue === 'boolean' &&
803
- (fieldInfo.DefaultValue === (fieldValue ? '1' : '0') ||
804
- fieldInfo.DefaultValue === (fieldValue ? 'true' : 'false'))) {
805
- includeField = false;
806
- }
807
- // Special handling for numeric defaults that might be strings
808
- else if (typeof fieldValue === 'number' && String(fieldValue) === String(fieldInfo.DefaultValue)) {
809
- includeField = false;
810
- }
811
- else {
812
- includeField = true;
813
- }
814
- }
815
- else {
816
- // No default value defined, include if not null/empty
817
- includeField = true;
818
- }
819
- }
820
- else {
821
- // No entity info, include if not null/empty
822
- includeField = true;
823
- }
824
- }
825
- if (includeField) {
826
- cleanedFields[fieldName] = fieldValue;
827
- }
828
- }
829
- // Calculate checksum on cleaned fields
830
- const checksum = syncEngine.calculateChecksum(cleanedFields);
831
- // Build the final record data with proper ordering
832
- // Use a new object to ensure property order
833
- const recordData = {};
834
- // 1. User fields first
835
- recordData.fields = cleanedFields;
836
- // 2. Related entities (if any)
837
- if (Object.keys(relatedEntities).length > 0) {
838
- recordData.relatedEntities = relatedEntities;
839
- }
840
- // 3. Primary key (system field)
841
- recordData.primaryKey = primaryKey;
842
- // 4. Sync metadata (system field)
843
- recordData.sync = {
844
- lastModified: new Date().toISOString(),
845
- checksum: checksum
846
- };
847
- return recordData;
848
- }
849
- /**
850
- * Convert a foreign key value to a @lookup reference
851
- *
852
- * Looks up the related record and creates a @lookup string that can be
853
- * resolved during push operations.
854
- *
855
- * @param foreignKeyValue - The foreign key value (ID)
856
- * @param targetEntity - Name of the target entity
857
- * @param targetField - Field in target entity to use for lookup
858
- * @param syncEngine - Sync engine instance
859
- * @returns @lookup string or null if lookup fails
860
- * @private
861
- */
862
- async convertToLookup(foreignKeyValue, targetEntity, targetField, syncEngine) {
863
- try {
864
- // Get the related record
865
- const metadata = new core_2.Metadata();
866
- const targetEntityInfo = metadata.EntityByName(targetEntity);
867
- if (!targetEntityInfo) {
868
- this.warn(`Could not find entity ${targetEntity} for lookup`);
869
- return null;
870
- }
871
- // Load the related record
872
- const primaryKeyField = targetEntityInfo.PrimaryKeys?.[0]?.Name || 'ID';
873
- const rv = new core_2.RunView();
874
- const result = await rv.RunView({
875
- EntityName: targetEntity,
876
- ExtraFilter: `${primaryKeyField} = '${String(foreignKeyValue).replace(/'/g, "''")}'`,
877
- ResultType: 'entity_object'
878
- }, (0, provider_utils_1.getSystemUser)());
879
- if (!result.Success || result.Results.length === 0) {
880
- this.warn(`Could not find ${targetEntity} with ${primaryKeyField} = ${foreignKeyValue}`);
881
- return null;
882
- }
883
- const relatedRecord = result.Results[0];
884
- const lookupValue = relatedRecord[targetField];
885
- if (!lookupValue) {
886
- this.warn(`${targetEntity} record missing ${targetField} field`);
887
- return null;
888
- }
889
- // Return the @lookup reference
890
- return `@lookup:${targetEntity}.${targetField}=${lookupValue}`;
891
- }
892
- catch (error) {
893
- this.warn(`Failed to create lookup for ${targetEntity}: ${error}`);
894
- return null;
895
- }
896
- }
897
- /**
898
- * Determine if a field should be saved to an external file
899
- *
900
- * Checks if a field is configured for externalization or contains substantial
901
- * text content that would be better stored in a separate file.
902
- *
903
- * @param fieldName - Name of the field to check
904
- * @param fieldValue - Value of the field
905
- * @param entityConfig - Entity configuration with externalization settings
906
- * @returns Promise resolving to true if field should be externalized
907
- * @private
908
- */
909
- async shouldExternalizeField(fieldName, fieldValue, entityConfig) {
910
- // Only externalize string fields
911
- if (typeof fieldValue !== 'string') {
912
- return false;
913
- }
914
- // Check if field is configured for externalization
915
- const externalizeConfig = entityConfig.pull?.externalizeFields;
916
- if (!externalizeConfig) {
917
- return false;
918
- }
919
- if (Array.isArray(externalizeConfig)) {
920
- if (externalizeConfig.length > 0 && typeof externalizeConfig[0] === 'string') {
921
- // Simple string array
922
- return externalizeConfig.includes(fieldName);
923
- }
924
- else {
925
- // New pattern format
926
- return externalizeConfig
927
- .some(item => item.field === fieldName);
928
- }
929
- }
930
- else {
931
- // Object format
932
- return fieldName in externalizeConfig;
933
- }
934
- }
935
- /**
936
- * Create an external file for a field value
937
- *
938
- * Saves large text content to a separate file and returns the filename.
939
- * Automatically determines appropriate file extension based on field name
940
- * and content type (e.g., .md for prompts, .html for templates).
941
- * Uses the entity's name field for the filename if available.
942
- *
943
- * @param targetDir - Directory to save the file
944
- * @param record - Full record to extract name field from
945
- * @param primaryKey - Primary key for filename generation fallback
946
- * @param fieldName - Name of the field being externalized
947
- * @param content - Content to write to the file
948
- * @param entityConfig - Entity configuration
949
- * @returns Promise resolving to the created filename
950
- * @private
951
- */
952
- async createExternalFile(targetDir, record, primaryKey, fieldName, content, entityConfig, pattern) {
953
- // If pattern is provided, use it to generate the full path
954
- if (pattern) {
955
- // Replace placeholders in the pattern
956
- let resolvedPattern = pattern;
957
- // Get entity metadata for field lookups
958
- const metadata = new core_2.Metadata();
959
- const entityInfo = metadata.EntityByName(entityConfig.entity);
960
- // Replace {Name} with the entity's name field value
961
- if (entityInfo) {
962
- const nameField = entityInfo.Fields.find(f => f.IsNameField);
963
- if (nameField && record[nameField.Name]) {
964
- const nameValue = String(record[nameField.Name])
965
- .replace(/[^a-zA-Z0-9\-_ ]/g, '') // Remove disallowed characters
966
- .replace(/\s+/g, '-') // Replace spaces with -
967
- .toLowerCase(); // Make lowercase
968
- resolvedPattern = resolvedPattern.replace(/{Name}/g, nameValue);
969
- }
970
- }
971
- // Replace {ID} with the primary key
972
- const idValue = primaryKey.ID || Object.values(primaryKey)[0];
973
- if (idValue) {
974
- resolvedPattern = resolvedPattern.replace(/{ID}/g, String(idValue).toLowerCase());
975
- }
976
- // Replace {FieldName} with the current field name
977
- resolvedPattern = resolvedPattern.replace(/{FieldName}/g, fieldName.toLowerCase());
978
- // Replace any other {field} placeholders with field values from the record
979
- const placeholderRegex = /{(\w+)}/g;
980
- resolvedPattern = resolvedPattern.replace(placeholderRegex, (match, fieldName) => {
981
- const value = record[fieldName];
982
- if (value !== undefined && value !== null) {
983
- return String(value)
984
- .replace(/[^a-zA-Z0-9\-_ ]/g, '')
985
- .replace(/\s+/g, '-')
986
- .toLowerCase();
987
- }
988
- return match; // Keep placeholder if field not found
989
- });
990
- // Extract the file path from the pattern
991
- const filePath = path_1.default.join(targetDir, resolvedPattern.replace('@file:', ''));
992
- // Ensure directory exists
993
- await fs_extra_1.default.ensureDir(path_1.default.dirname(filePath));
994
- // Write the file
995
- await fs_extra_1.default.writeFile(filePath, content, 'utf-8');
996
- // Return the pattern as-is (it includes @file: prefix)
997
- return resolvedPattern;
998
- }
999
- // Original logic for non-pattern based externalization
1000
- let extension = '.md'; // default to markdown
1001
- const externalizeConfig = entityConfig.pull?.externalizeFields;
1002
- if (externalizeConfig && !Array.isArray(externalizeConfig) && externalizeConfig[fieldName]?.extension) {
1003
- extension = externalizeConfig[fieldName].extension;
1004
- // Ensure extension starts with a dot
1005
- if (!extension.startsWith('.')) {
1006
- extension = '.' + extension;
1007
- }
1008
- }
1009
- // Try to use the entity's name field for the filename
1010
- let baseFileName;
1011
- // Get entity metadata to find the name field
1012
- const metadata = new core_2.Metadata();
1013
- const entityInfo = metadata.EntityByName(entityConfig.entity);
1014
- if (entityInfo) {
1015
- // Find the name field
1016
- const nameField = entityInfo.Fields.find(f => f.IsNameField);
1017
- if (nameField && record[nameField.Name]) {
1018
- // Use the name field value, sanitized for filesystem
1019
- const nameValue = String(record[nameField.Name]);
1020
- // Remove disallowed characters (don't replace with _), replace spaces with -, and lowercase
1021
- baseFileName = nameValue
1022
- .replace(/[^a-zA-Z0-9\-_ ]/g, '') // Remove disallowed characters
1023
- .replace(/\s+/g, '-') // Replace spaces with -
1024
- .toLowerCase(); // Make lowercase
1025
- }
1026
- else {
1027
- // Fallback to primary key
1028
- baseFileName = this.buildFileName(primaryKey, null).replace('.json', '');
1029
- }
1030
- }
1031
- else {
1032
- // Fallback to primary key
1033
- baseFileName = this.buildFileName(primaryKey, null).replace('.json', '');
1034
- }
1035
- // Remove dot prefix from baseFileName if it exists (it will be a dot-prefixed name from buildFileName)
1036
- const cleanBaseFileName = baseFileName.startsWith('.') ? baseFileName.substring(1) : baseFileName;
1037
- const fileName = `.${cleanBaseFileName}.${fieldName.toLowerCase()}${extension}`;
1038
- const filePath = path_1.default.join(targetDir, fileName);
1039
- await fs_extra_1.default.writeFile(filePath, content, 'utf-8');
1040
- return fileName;
1041
- }
1042
- /**
1043
- * Build a filename from primary key values
1044
- *
1045
- * Creates a safe filename based on the entity's primary key values.
1046
- * Handles GUIDs by using first 8 characters, sanitizes special characters,
1047
- * and creates composite names for multi-field keys.
1048
- * Files are prefixed with a dot to follow the metadata file convention.
1049
- *
1050
- * @param primaryKey - Primary key fields and values
1051
- * @param entityConfig - Entity configuration (for future extension)
1052
- * @returns Filename with .json extension
1053
- * @private
1054
- */
1055
- buildFileName(primaryKey, entityConfig) {
1056
- // Use primary key values to build filename
1057
- const keys = Object.values(primaryKey);
1058
- if (keys.length === 1 && typeof keys[0] === 'string') {
1059
- // Single string key - use as base if it's a guid
1060
- const key = keys[0];
1061
- if (key.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
1062
- // It's a GUID, use first 8 chars, prefixed with dot, lowercase
1063
- return `.${key.substring(0, 8).toLowerCase()}.json`;
1064
- }
1065
- // Use the whole key if not too long, prefixed with dot
1066
- if (key.length <= 50) {
1067
- return `.${key.replace(/[^a-zA-Z0-9\-_]/g, '').toLowerCase()}.json`;
1068
- }
1069
- }
1070
- // Multiple keys or numeric - create composite name, prefixed with dot
1071
- return '.' + keys.map(k => String(k).replace(/[^a-zA-Z0-9\-_]/g, '').toLowerCase()).join('-') + '.json';
1072
- }
1073
- /**
1074
- * Pull related entities for a parent record
1075
- *
1076
- * Retrieves child records that have foreign key relationships to the parent.
1077
- * Converts foreign key values to @parent references and supports nested
1078
- * related entities for deep object graphs.
1079
- * NEW: Supports automatic recursive patterns for self-referencing entities.
1080
- *
1081
- * @param parentRecord - Parent entity record
1082
- * @param relatedConfig - Configuration for related entities to pull
1083
- * @param syncEngine - Sync engine instance
1084
- * @param entityConfig - Entity configuration
1085
- * @param flags - Command flags
1086
- * @param currentDepth - Current recursion depth for recursive entities
1087
- * @param ancestryPath - Set of IDs in current ancestry chain to prevent circular references
1088
- * @returns Promise resolving to map of entity names to related records
1089
- * @private
1090
- */
1091
- async pullRelatedEntities(parentRecord, relatedConfig, syncEngine, entityConfig, flags, currentDepth = 0, ancestryPath = new Set()) {
1092
- const relatedEntities = {};
1093
- for (const [key, config] of Object.entries(relatedConfig)) {
1094
- try {
1095
- // Get entity metadata to find primary key
1096
- const metadata = new core_2.Metadata();
1097
- const parentEntity = metadata.EntityByName(entityConfig.entity);
1098
- if (!parentEntity) {
1099
- this.warn(`Could not find entity metadata for ${entityConfig.entity}`);
1100
- continue;
1101
- }
1102
- // Get the parent's primary key value (usually ID)
1103
- const primaryKeyField = parentEntity.PrimaryKeys?.[0]?.Name || 'ID';
1104
- const parentKeyValue = parentRecord[primaryKeyField];
1105
- if (!parentKeyValue) {
1106
- this.warn(`Parent record missing primary key field ${primaryKeyField}`);
1107
- continue;
1108
- }
1109
- // Build filter for related records
1110
- // The foreignKey is the field in the CHILD entity that points to this parent
1111
- let filter = `${config.foreignKey} = '${String(parentKeyValue).replace(/'/g, "''")}'`;
1112
- if (config.filter) {
1113
- filter += ` AND (${config.filter})`;
1114
- }
1115
- // Pull related records
1116
- const rv = new core_2.RunView();
1117
- const result = await rv.RunView({
1118
- EntityName: config.entity,
1119
- ExtraFilter: filter,
1120
- ResultType: 'entity_object'
1121
- }, (0, provider_utils_1.getSystemUser)());
1122
- if (!result.Success) {
1123
- this.warn(`Failed to pull related ${config.entity}: ${result.ErrorMessage}`);
1124
- continue;
1125
- }
1126
- // Get child entity metadata
1127
- const childEntity = metadata.EntityByName(config.entity);
1128
- if (!childEntity) {
1129
- this.warn(`Could not find entity metadata for ${config.entity}`);
1130
- continue;
1131
- }
1132
- // Check if we need to wait for async property loading for related entities
1133
- if (config.externalizeFields && result.Results.length > 0) {
1134
- let fieldsToExternalize = [];
1135
- if (Array.isArray(config.externalizeFields)) {
1136
- if (config.externalizeFields.length > 0 && typeof config.externalizeFields[0] === 'string') {
1137
- // Simple string array
1138
- fieldsToExternalize = config.externalizeFields;
1139
- }
1140
- else {
1141
- // New pattern format
1142
- fieldsToExternalize = config.externalizeFields
1143
- .map(item => item.field);
1144
- }
1145
- }
1146
- else {
1147
- // Object format
1148
- fieldsToExternalize = Object.keys(config.externalizeFields);
1149
- }
1150
- // Get all field names from entity metadata
1151
- const metadataFieldNames = childEntity.Fields.map(f => f.Name);
1152
- // Check if any externalized fields are NOT in metadata (likely computed properties)
1153
- const computedFields = fieldsToExternalize.filter(f => !metadataFieldNames.includes(f));
1154
- if (computedFields.length > 0) {
1155
- if (flags?.verbose) {
1156
- console.log(`Waiting 5 seconds for async property loading in related entity ${config.entity} (${computedFields.join(', ')})...`);
1157
- }
1158
- await new Promise(resolve => setTimeout(resolve, 5000));
1159
- }
1160
- }
1161
- // Process each related record
1162
- const relatedRecords = [];
1163
- if (flags?.verbose && result.Results.length > 0) {
1164
- this.log(`Found ${result.Results.length} related ${config.entity} records at depth ${currentDepth}`);
1165
- }
1166
- for (const relatedRecord of result.Results) {
1167
- // Build primary key for the related record
1168
- const relatedPrimaryKey = {};
1169
- for (const pk of childEntity.PrimaryKeys) {
1170
- relatedPrimaryKey[pk.Name] = relatedRecord[pk.Name];
1171
- }
1172
- // Check for circular references in the current ancestry path
1173
- const recordId = String(relatedPrimaryKey[childEntity.PrimaryKeys[0]?.Name || 'ID']);
1174
- if (config.recursive && ancestryPath.has(recordId)) {
1175
- if (flags?.verbose) {
1176
- this.log(`Skipping circular reference for ${config.entity} with ID: ${recordId} (detected in ancestry path)`);
1177
- }
1178
- continue;
1179
- }
1180
- // Create new ancestry path for this branch (only track current hierarchy chain)
1181
- const newAncestryPath = new Set(ancestryPath);
1182
- if (config.recursive) {
1183
- newAncestryPath.add(recordId);
1184
- }
1185
- // Determine related entities configuration for recursion
1186
- let childRelatedConfig = config.relatedEntities;
1187
- // If recursive is enabled, continue recursive fetching at child level
1188
- if (config.recursive) {
1189
- const maxDepth = config.maxDepth || 10;
1190
- if (currentDepth < maxDepth) {
1191
- // Create recursive configuration that references the same entity
1192
- childRelatedConfig = {
1193
- [key]: {
1194
- ...config,
1195
- // Keep same configuration but increment depth internally
1196
- }
1197
- };
1198
- if (flags?.verbose) {
1199
- this.log(`Processing recursive level ${currentDepth + 1} for ${config.entity} record ${recordId}`);
1200
- }
1201
- }
1202
- else {
1203
- // At max depth, don't recurse further
1204
- childRelatedConfig = undefined;
1205
- if (flags?.verbose) {
1206
- this.log(`Max depth ${maxDepth} reached for recursive entity ${config.entity} at record ${recordId}`);
1207
- }
1208
- }
1209
- }
1210
- // Process the related record using the same logic as parent records
1211
- const relatedData = await this.processRecordData(relatedRecord, relatedPrimaryKey, '', // Not used for related entities since we don't externalize their fields
1212
- {
1213
- entity: config.entity,
1214
- pull: {
1215
- excludeFields: config.excludeFields || entityConfig.pull?.excludeFields,
1216
- lookupFields: config.lookupFields || entityConfig.pull?.lookupFields,
1217
- externalizeFields: config.externalizeFields,
1218
- relatedEntities: childRelatedConfig
1219
- }
1220
- }, syncEngine, flags, true, // isNewRecord
1221
- undefined, // existingRecordData
1222
- currentDepth + 1, newAncestryPath);
1223
- // Convert foreign key reference to @parent
1224
- if (relatedData.fields[config.foreignKey]) {
1225
- relatedData.fields[config.foreignKey] = `@parent:${primaryKeyField}`;
1226
- }
1227
- // The processRecordData method already filters nulls and defaults
1228
- // No need to do it again here
1229
- relatedRecords.push(relatedData);
1230
- }
1231
- if (relatedRecords.length > 0) {
1232
- relatedEntities[key] = relatedRecords;
1233
- }
1234
- }
1235
- catch (error) {
1236
- this.warn(`Error pulling related ${key}: ${error}`);
1237
- }
1238
- }
1239
- return relatedEntities;
1240
- }
1241
- /**
1242
- * Find which field in the parent record contains a specific value
1243
- *
1244
- * Used to convert foreign key references to @parent references by finding
1245
- * the parent field that contains the foreign key value. Typically finds
1246
- * the primary key field but can match any field.
1247
- *
1248
- * @param parentRecord - Parent record to search
1249
- * @param value - Value to search for
1250
- * @returns Field name containing the value, or null if not found
1251
- * @private
1252
- */
1253
- findParentField(parentRecord, value) {
1254
- // Find which field in the parent contains this value
1255
- // Typically this will be the primary key field
1256
- for (const [fieldName, fieldValue] of Object.entries(parentRecord)) {
1257
- if (fieldValue === value && !fieldName.startsWith('__mj_')) {
1258
- return fieldName;
1259
- }
1260
- }
1261
- return null;
1262
- }
1263
- /**
1264
- * Find existing files in a directory matching a pattern
1265
- *
1266
- * Searches for files that match the configured file pattern, used to identify
1267
- * which records already exist locally for smart update functionality.
1268
- *
1269
- * @param dir - Directory to search in
1270
- * @param pattern - Glob pattern to match files (e.g., "*.json")
1271
- * @returns Promise resolving to array of file paths
1272
- * @private
1273
- */
1274
- async findExistingFiles(dir, pattern) {
1275
- const files = [];
1276
- try {
1277
- const entries = await fs_extra_1.default.readdir(dir, { withFileTypes: true });
1278
- for (const entry of entries) {
1279
- if (entry.isFile()) {
1280
- const fileName = entry.name;
1281
- // Simple pattern matching - could be enhanced with proper glob support
1282
- if (pattern === '*.json' && fileName.endsWith('.json')) {
1283
- files.push(path_1.default.join(dir, fileName));
1284
- }
1285
- else if (pattern === '.*.json' && fileName.startsWith('.') && fileName.endsWith('.json')) {
1286
- // Handle dot-prefixed JSON files
1287
- files.push(path_1.default.join(dir, fileName));
1288
- }
1289
- else if (pattern === fileName) {
1290
- files.push(path_1.default.join(dir, fileName));
1291
- }
1292
- // TODO: Add more sophisticated glob pattern matching if needed
1293
- }
1294
- }
1295
- }
1296
- catch (error) {
1297
- // Directory might not exist yet
1298
- if (error.code !== 'ENOENT') {
1299
- throw error;
1300
- }
1301
- }
1302
- return files;
1303
- }
1304
- /**
1305
- * Load existing records from files and build a lookup map
1306
- *
1307
- * Reads all existing files and creates a map from primary key to file location,
1308
- * enabling efficient lookup during the update process.
1309
- *
1310
- * @param files - Array of file paths to load
1311
- * @param entityInfo - Entity metadata for primary key information
1312
- * @returns Map from primary key string to file info
1313
- * @private
1314
- */
1315
- async loadExistingRecords(files, entityInfo) {
1316
- const recordsMap = new Map();
1317
- for (const filePath of files) {
1318
- try {
1319
- const fileData = await fs_extra_1.default.readJson(filePath);
1320
- const records = Array.isArray(fileData) ? fileData : [fileData];
1321
- for (const record of records) {
1322
- if (record.primaryKey) {
1323
- const lookupKey = this.createPrimaryKeyLookup(record.primaryKey);
1324
- recordsMap.set(lookupKey, { filePath, recordData: record });
1325
- }
1326
- }
1327
- }
1328
- catch (error) {
1329
- // Skip files that can't be parsed
1330
- this.warn(`Could not load file ${filePath}: ${error}`);
1331
- }
1332
- }
1333
- return recordsMap;
1334
- }
1335
- /**
1336
- * Create a string lookup key from primary key values
1337
- *
1338
- * Generates a consistent string representation of primary key values
1339
- * for use in maps and comparisons.
1340
- *
1341
- * @param primaryKey - Primary key field names and values
1342
- * @returns String representation of the primary key
1343
- * @private
1344
- */
1345
- createPrimaryKeyLookup(primaryKey) {
1346
- const keys = Object.keys(primaryKey).sort();
1347
- return keys.map(k => `${k}:${primaryKey[k]}`).join('|');
1348
- }
1349
- /**
1350
- * Merge two record data objects based on configured strategy
1351
- *
1352
- * Combines existing and new record data according to the merge strategy:
1353
- * - 'overwrite': Replace all fields with new values
1354
- * - 'merge': Combine fields, with new values taking precedence
1355
- * - 'skip': Keep existing record unchanged
1356
- *
1357
- * @param existing - Existing record data
1358
- * @param newData - New record data from database
1359
- * @param strategy - Merge strategy to apply
1360
- * @param preserveFields - Field names that should never be overwritten
1361
- * @returns Merged record data
1362
- * @private
1363
- */
1364
- async mergeRecords(existing, newData, strategy, preserveFields) {
1365
- if (strategy === 'skip') {
1366
- return existing;
1367
- }
1368
- if (strategy === 'overwrite') {
1369
- // Build with proper ordering
1370
- const result = {};
1371
- // 1. Fields first
1372
- result.fields = { ...newData.fields };
1373
- // Restore preserved fields from existing
1374
- if (preserveFields.length > 0 && existing.fields) {
1375
- for (const field of preserveFields) {
1376
- if (field in existing.fields) {
1377
- result.fields[field] = existing.fields[field];
1378
- }
1379
- }
1380
- }
1381
- // 2. Related entities (if any)
1382
- if (newData.relatedEntities) {
1383
- result.relatedEntities = newData.relatedEntities;
1384
- }
1385
- // 3. Primary key
1386
- result.primaryKey = newData.primaryKey;
1387
- // 4. Sync metadata
1388
- result.sync = newData.sync;
1389
- return result;
1390
- }
1391
- // Default 'merge' strategy
1392
- // Build with proper ordering
1393
- const result = {};
1394
- // 1. Fields first
1395
- result.fields = { ...existing.fields, ...newData.fields };
1396
- // Restore preserved fields
1397
- if (preserveFields.length > 0 && existing.fields) {
1398
- for (const field of preserveFields) {
1399
- if (field in existing.fields) {
1400
- result.fields[field] = existing.fields[field];
1401
- }
1402
- }
1403
- }
1404
- // 2. Related entities (if any)
1405
- if (existing.relatedEntities || newData.relatedEntities) {
1406
- result.relatedEntities = {
1407
- ...existing.relatedEntities,
1408
- ...newData.relatedEntities
1409
- };
1410
- }
1411
- // 3. Primary key
1412
- result.primaryKey = newData.primaryKey || existing.primaryKey;
1413
- // 4. Sync metadata
1414
- result.sync = newData.sync;
1415
- return result;
1416
- }
1417
- /**
1418
- * Create a backup of a file before updating
1419
- *
1420
- * Creates a timestamped backup copy of the file in a backup directory
1421
- * with the original filename, timestamp suffix, and .backup extension.
1422
- * The backup directory defaults to .backups but can be configured.
1423
- *
1424
- * @param filePath - Path to the file to backup
1425
- * @param backupDirName - Name of the backup directory (optional)
1426
- * @returns Promise that resolves when backup is created
1427
- * @private
1428
- */
1429
- async createBackup(filePath, backupDirName) {
1430
- const dir = path_1.default.dirname(filePath);
1431
- const fileName = path_1.default.basename(filePath);
1432
- const backupDir = path_1.default.join(dir, backupDirName || '.backups');
1433
- // Ensure backup directory exists
1434
- await fs_extra_1.default.ensureDir(backupDir);
1435
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1436
- // Remove .json extension, add timestamp, then add .backup extension
1437
- const backupFileName = fileName.replace(/\.json$/, `.${timestamp}.backup`);
1438
- const backupPath = path_1.default.join(backupDir, backupFileName);
1439
- try {
1440
- await fs_extra_1.default.copy(filePath, backupPath);
1441
- }
1442
- catch (error) {
1443
- this.warn(`Could not create backup of ${filePath}: ${error}`);
1444
- }
1445
- }
1446
- }
1447
- exports.default = Pull;
1448
- //# sourceMappingURL=index.js.map