@memberjunction/metadata-sync 2.51.0 → 2.53.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 +138 -1
- package/dist/commands/file-reset/index.d.ts +15 -0
- package/dist/commands/file-reset/index.js +221 -0
- package/dist/commands/file-reset/index.js.map +1 -0
- package/dist/commands/push/index.d.ts +21 -0
- package/dist/commands/push/index.js +502 -42
- package/dist/commands/push/index.js.map +1 -1
- package/dist/commands/watch/index.js +39 -1
- package/dist/commands/watch/index.js.map +1 -1
- package/dist/config.d.ts +30 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/file-backup-manager.d.ts +90 -0
- package/dist/lib/file-backup-manager.js +186 -0
- package/dist/lib/file-backup-manager.js.map +1 -0
- package/dist/lib/provider-utils.d.ts +4 -2
- package/dist/lib/provider-utils.js +26 -4
- package/dist/lib/provider-utils.js.map +1 -1
- package/dist/lib/sync-engine.d.ts +4 -1
- package/dist/lib/sync-engine.js +50 -16
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/services/FormattingService.d.ts +1 -0
- package/dist/services/FormattingService.js +4 -1
- package/dist/services/FormattingService.js.map +1 -1
- package/dist/services/ValidationService.d.ts +9 -0
- package/dist/services/ValidationService.js +268 -70
- package/dist/services/ValidationService.js.map +1 -1
- package/dist/types/validation.d.ts +6 -2
- package/dist/types/validation.js.map +1 -1
- package/oclif.manifest.json +114 -45
- package/package.json +7 -7
|
@@ -38,11 +38,14 @@ const provider_utils_1 = require("../../lib/provider-utils");
|
|
|
38
38
|
const core_2 = require("@memberjunction/core");
|
|
39
39
|
const config_manager_1 = require("../../lib/config-manager");
|
|
40
40
|
const singleton_manager_1 = require("../../lib/singleton-manager");
|
|
41
|
+
const sqlserver_dataprovider_1 = require("@memberjunction/sqlserver-dataprovider");
|
|
41
42
|
const global_1 = require("@memberjunction/global");
|
|
43
|
+
const file_backup_manager_1 = require("../../lib/file-backup-manager");
|
|
42
44
|
class Push extends core_1.Command {
|
|
43
45
|
static description = 'Push local file changes to the database';
|
|
44
46
|
warnings = [];
|
|
45
47
|
errors = [];
|
|
48
|
+
processedRecords = new Map();
|
|
46
49
|
static examples = [
|
|
47
50
|
`<%= config.bin %> <%= command.id %>`,
|
|
48
51
|
`<%= config.bin %> <%= command.id %> --dry-run`,
|
|
@@ -66,7 +69,11 @@ class Push extends core_1.Command {
|
|
|
66
69
|
const { flags } = await this.parse(Push);
|
|
67
70
|
const spinner = (0, ora_classic_1.default)();
|
|
68
71
|
let sqlLogger = null;
|
|
72
|
+
const fileBackupManager = new file_backup_manager_1.FileBackupManager();
|
|
73
|
+
let hasActiveTransaction = false;
|
|
69
74
|
const startTime = Date.now();
|
|
75
|
+
// Reset the processed records tracking for this push operation
|
|
76
|
+
this.processedRecords.clear();
|
|
70
77
|
try {
|
|
71
78
|
// Load configurations
|
|
72
79
|
spinner.start('Loading configuration');
|
|
@@ -130,7 +137,7 @@ class Push extends core_1.Command {
|
|
|
130
137
|
}
|
|
131
138
|
}
|
|
132
139
|
// Find entity directories to process
|
|
133
|
-
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);
|
|
134
141
|
if (entityDirs.length === 0) {
|
|
135
142
|
this.error('No entity directories found');
|
|
136
143
|
}
|
|
@@ -161,7 +168,9 @@ class Push extends core_1.Command {
|
|
|
161
168
|
default: false
|
|
162
169
|
});
|
|
163
170
|
if (!shouldContinue) {
|
|
164
|
-
this.
|
|
171
|
+
this.log(chalk_1.default.yellow('\n⚠️ Push cancelled due to validation errors.'));
|
|
172
|
+
// Exit cleanly without throwing an error
|
|
173
|
+
return;
|
|
165
174
|
}
|
|
166
175
|
}
|
|
167
176
|
}
|
|
@@ -169,31 +178,69 @@ class Push extends core_1.Command {
|
|
|
169
178
|
this.log(chalk_1.default.green('✓ Validation passed'));
|
|
170
179
|
}
|
|
171
180
|
}
|
|
181
|
+
// Initialize file backup manager (unless in dry-run mode)
|
|
182
|
+
if (!flags['dry-run']) {
|
|
183
|
+
await fileBackupManager.initialize();
|
|
184
|
+
if (flags.verbose) {
|
|
185
|
+
this.log('📁 File backup manager initialized');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
172
188
|
// Start a database transaction for the entire push operation (unless in dry-run mode)
|
|
173
189
|
// IMPORTANT: We start the transaction AFTER metadata loading and validation to avoid
|
|
174
190
|
// transaction conflicts with background refresh operations
|
|
175
191
|
if (!flags['dry-run']) {
|
|
176
192
|
const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
|
|
177
193
|
const dataProvider = getDataProvider();
|
|
194
|
+
// Ensure we have SQLServerDataProvider for transaction support
|
|
195
|
+
if (!(dataProvider instanceof sqlserver_dataprovider_1.SQLServerDataProvider)) {
|
|
196
|
+
const errorMsg = 'MetadataSync requires SQLServerDataProvider for transaction support. Current provider does not support transactions.';
|
|
197
|
+
// Rollback file backups since we're not proceeding
|
|
198
|
+
try {
|
|
199
|
+
await fileBackupManager.rollback();
|
|
200
|
+
}
|
|
201
|
+
catch (rollbackError) {
|
|
202
|
+
this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
|
|
203
|
+
}
|
|
204
|
+
this.error(errorMsg);
|
|
205
|
+
}
|
|
178
206
|
if (dataProvider && typeof dataProvider.BeginTransaction === 'function') {
|
|
179
207
|
try {
|
|
180
208
|
await dataProvider.BeginTransaction();
|
|
209
|
+
hasActiveTransaction = true;
|
|
181
210
|
if (flags.verbose) {
|
|
182
211
|
this.log('🔄 Transaction started - all changes will be committed or rolled back as a unit');
|
|
183
212
|
}
|
|
184
213
|
}
|
|
185
214
|
catch (error) {
|
|
186
|
-
|
|
187
|
-
|
|
215
|
+
// Transaction start failure is critical - we should not proceed without it
|
|
216
|
+
const errorMsg = `Failed to start database transaction: ${error instanceof Error ? error.message : String(error)}`;
|
|
217
|
+
// Rollback file backups since we're not proceeding
|
|
218
|
+
try {
|
|
219
|
+
await fileBackupManager.rollback();
|
|
220
|
+
}
|
|
221
|
+
catch (rollbackError) {
|
|
222
|
+
this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
|
|
223
|
+
}
|
|
224
|
+
this.error(errorMsg);
|
|
188
225
|
}
|
|
189
226
|
}
|
|
190
227
|
else {
|
|
191
|
-
|
|
228
|
+
// No transaction support is also critical for data integrity
|
|
229
|
+
const errorMsg = 'Transaction support not available - cannot ensure data integrity';
|
|
230
|
+
// Rollback file backups since we're not proceeding
|
|
231
|
+
try {
|
|
232
|
+
await fileBackupManager.rollback();
|
|
233
|
+
}
|
|
234
|
+
catch (rollbackError) {
|
|
235
|
+
this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
|
|
236
|
+
}
|
|
237
|
+
this.error(errorMsg);
|
|
192
238
|
}
|
|
193
239
|
}
|
|
194
240
|
// Process each entity directory
|
|
195
241
|
let totalCreated = 0;
|
|
196
242
|
let totalUpdated = 0;
|
|
243
|
+
let totalUnchanged = 0;
|
|
197
244
|
let totalErrors = 0;
|
|
198
245
|
for (const entityDir of entityDirs) {
|
|
199
246
|
const entityConfig = await (0, config_1.loadEntityConfig)(entityDir);
|
|
@@ -204,9 +251,34 @@ class Push extends core_1.Command {
|
|
|
204
251
|
if (flags.verbose) {
|
|
205
252
|
this.log(`\nProcessing ${entityConfig.entity} in ${entityDir}`);
|
|
206
253
|
}
|
|
207
|
-
|
|
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);
|
|
260
|
+
// Show per-directory summary
|
|
261
|
+
const dirName = path_1.default.relative(process.cwd(), entityDir) || '.';
|
|
262
|
+
const dirTotal = result.created + result.updated + result.unchanged;
|
|
263
|
+
if (dirTotal > 0 || result.errors > 0) {
|
|
264
|
+
this.log(`\n📁 ${dirName}:`);
|
|
265
|
+
this.log(` Total processed: ${dirTotal} unique records`);
|
|
266
|
+
if (result.created > 0) {
|
|
267
|
+
this.log(` ✓ Created: ${result.created}`);
|
|
268
|
+
}
|
|
269
|
+
if (result.updated > 0) {
|
|
270
|
+
this.log(` ✓ Updated: ${result.updated}`);
|
|
271
|
+
}
|
|
272
|
+
if (result.unchanged > 0) {
|
|
273
|
+
this.log(` - Unchanged: ${result.unchanged}`);
|
|
274
|
+
}
|
|
275
|
+
if (result.errors > 0) {
|
|
276
|
+
this.log(` ✗ Errors: ${result.errors}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
208
279
|
totalCreated += result.created;
|
|
209
280
|
totalUpdated += result.updated;
|
|
281
|
+
totalUnchanged += result.unchanged;
|
|
210
282
|
totalErrors += result.errors;
|
|
211
283
|
}
|
|
212
284
|
// Summary using FormattingService
|
|
@@ -216,15 +288,16 @@ class Push extends core_1.Command {
|
|
|
216
288
|
this.log('\n' + formatter.formatSyncSummary('push', {
|
|
217
289
|
created: totalCreated,
|
|
218
290
|
updated: totalUpdated,
|
|
291
|
+
unchanged: totalUnchanged,
|
|
219
292
|
deleted: 0,
|
|
220
293
|
skipped: 0,
|
|
221
294
|
errors: totalErrors,
|
|
222
295
|
duration: endTime - startTime
|
|
223
296
|
}));
|
|
224
297
|
// Handle transaction commit/rollback
|
|
225
|
-
if (!flags['dry-run']) {
|
|
298
|
+
if (!flags['dry-run'] && hasActiveTransaction) {
|
|
226
299
|
const dataProvider = core_2.Metadata.Provider;
|
|
227
|
-
//
|
|
300
|
+
// We know we have an active transaction at this point
|
|
228
301
|
if (dataProvider) {
|
|
229
302
|
let shouldCommit = true;
|
|
230
303
|
// If there are any errors, always rollback
|
|
@@ -261,12 +334,18 @@ class Push extends core_1.Command {
|
|
|
261
334
|
if (shouldCommit) {
|
|
262
335
|
await dataProvider.CommitTransaction();
|
|
263
336
|
this.log('\n✅ All changes committed successfully');
|
|
337
|
+
// Clean up file backups after successful commit
|
|
338
|
+
await fileBackupManager.cleanup();
|
|
264
339
|
}
|
|
265
340
|
else {
|
|
266
341
|
// User chose to rollback or errors/warnings in CI mode
|
|
267
342
|
this.log('\n🔙 Rolling back all changes...');
|
|
343
|
+
// Rollback database transaction
|
|
268
344
|
await dataProvider.RollbackTransaction();
|
|
269
|
-
|
|
345
|
+
// Rollback file changes
|
|
346
|
+
this.log('🔙 Rolling back file changes...');
|
|
347
|
+
await fileBackupManager.rollback();
|
|
348
|
+
this.log('✅ Rollback completed - no changes were made to the database or files');
|
|
270
349
|
}
|
|
271
350
|
}
|
|
272
351
|
catch (error) {
|
|
@@ -274,10 +353,19 @@ class Push extends core_1.Command {
|
|
|
274
353
|
this.log('\n❌ Transaction error - attempting to roll back changes');
|
|
275
354
|
try {
|
|
276
355
|
await dataProvider.RollbackTransaction();
|
|
277
|
-
this.log('✅
|
|
356
|
+
this.log('✅ Database rollback completed');
|
|
278
357
|
}
|
|
279
358
|
catch (rollbackError) {
|
|
280
|
-
this.log('❌
|
|
359
|
+
this.log('❌ Database rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
|
|
360
|
+
}
|
|
361
|
+
// Also rollback file changes
|
|
362
|
+
try {
|
|
363
|
+
this.log('🔙 Rolling back file changes...');
|
|
364
|
+
await fileBackupManager.rollback();
|
|
365
|
+
this.log('✅ File rollback completed');
|
|
366
|
+
}
|
|
367
|
+
catch (fileRollbackError) {
|
|
368
|
+
this.log('❌ File rollback failed: ' + (fileRollbackError instanceof Error ? fileRollbackError.message : String(fileRollbackError)));
|
|
281
369
|
}
|
|
282
370
|
throw error;
|
|
283
371
|
}
|
|
@@ -290,20 +378,30 @@ class Push extends core_1.Command {
|
|
|
290
378
|
}
|
|
291
379
|
catch (error) {
|
|
292
380
|
spinner.fail('Push failed');
|
|
293
|
-
// Try to rollback the transaction if
|
|
381
|
+
// Try to rollback the transaction and files if not in dry-run mode
|
|
294
382
|
if (!flags['dry-run']) {
|
|
295
383
|
const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
|
|
296
384
|
const dataProvider = getDataProvider();
|
|
297
|
-
|
|
385
|
+
// Rollback database transaction if we have one
|
|
386
|
+
if (hasActiveTransaction && dataProvider && typeof dataProvider.RollbackTransaction === 'function') {
|
|
298
387
|
try {
|
|
299
|
-
this.log('\n🔙 Rolling back transaction due to error...');
|
|
388
|
+
this.log('\n🔙 Rolling back database transaction due to error...');
|
|
300
389
|
await dataProvider.RollbackTransaction();
|
|
301
|
-
this.log('✅
|
|
390
|
+
this.log('✅ Database rollback completed');
|
|
302
391
|
}
|
|
303
392
|
catch (rollbackError) {
|
|
304
|
-
this.log('❌
|
|
393
|
+
this.log('❌ Database rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
|
|
305
394
|
}
|
|
306
395
|
}
|
|
396
|
+
// Rollback file changes
|
|
397
|
+
try {
|
|
398
|
+
this.log('🔙 Rolling back file changes...');
|
|
399
|
+
await fileBackupManager.rollback();
|
|
400
|
+
this.log('✅ File rollback completed - all files restored to original state');
|
|
401
|
+
}
|
|
402
|
+
catch (fileRollbackError) {
|
|
403
|
+
this.log('❌ File rollback failed: ' + (fileRollbackError instanceof Error ? fileRollbackError.message : String(fileRollbackError)));
|
|
404
|
+
}
|
|
307
405
|
}
|
|
308
406
|
// Enhanced error logging for debugging
|
|
309
407
|
this.log('\n=== Push Error Details ===');
|
|
@@ -353,8 +451,8 @@ class Push extends core_1.Command {
|
|
|
353
451
|
process.exit(0);
|
|
354
452
|
}
|
|
355
453
|
}
|
|
356
|
-
async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig) {
|
|
357
|
-
const result = { created: 0, updated: 0, errors: 0 };
|
|
454
|
+
async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager, parentIgnoreDirectories) {
|
|
455
|
+
const result = { created: 0, updated: 0, unchanged: 0, errors: 0 };
|
|
358
456
|
// Find files matching the configured pattern
|
|
359
457
|
const pattern = entityConfig.filePattern || '*.json';
|
|
360
458
|
const jsonFiles = await (0, fast_glob_1.default)(pattern, {
|
|
@@ -363,15 +461,83 @@ class Push extends core_1.Command {
|
|
|
363
461
|
dot: true, // Include dotfiles (files starting with .)
|
|
364
462
|
onlyFiles: true
|
|
365
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
|
+
}
|
|
366
514
|
if (flags.verbose) {
|
|
367
515
|
this.log(`Processing ${jsonFiles.length} records in ${path_1.default.relative(process.cwd(), entityDir) || '.'}`);
|
|
368
516
|
}
|
|
369
517
|
// First, process all JSON files in this directory
|
|
370
|
-
await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result);
|
|
518
|
+
await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager);
|
|
371
519
|
// Then, recursively process subdirectories
|
|
372
520
|
const entries = await fs_extra_1.default.readdir(entityDir, { withFileTypes: true });
|
|
373
521
|
for (const entry of entries) {
|
|
374
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
|
+
}
|
|
375
541
|
const subDir = path_1.default.join(entityDir, entry.name);
|
|
376
542
|
// Load subdirectory config and merge with parent config
|
|
377
543
|
let subEntityConfig = { ...entityConfig };
|
|
@@ -392,48 +558,69 @@ class Push extends core_1.Command {
|
|
|
392
558
|
}
|
|
393
559
|
};
|
|
394
560
|
}
|
|
395
|
-
// Process subdirectory with merged config
|
|
396
|
-
const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig);
|
|
561
|
+
// Process subdirectory with merged config and cumulative ignore directories
|
|
562
|
+
const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig, fileBackupManager, cumulativeIgnoreDirectories);
|
|
397
563
|
result.created += subResult.created;
|
|
398
564
|
result.updated += subResult.updated;
|
|
565
|
+
result.unchanged += subResult.unchanged;
|
|
399
566
|
result.errors += subResult.errors;
|
|
400
567
|
}
|
|
401
568
|
}
|
|
402
569
|
return result;
|
|
403
570
|
}
|
|
404
|
-
async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result) {
|
|
571
|
+
async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager) {
|
|
405
572
|
if (jsonFiles.length === 0) {
|
|
406
573
|
return;
|
|
407
574
|
}
|
|
408
575
|
const spinner = (0, ora_classic_1.default)();
|
|
409
576
|
spinner.start('Processing records');
|
|
410
|
-
let totalRecords = 0;
|
|
411
577
|
for (const file of jsonFiles) {
|
|
412
578
|
try {
|
|
413
579
|
const filePath = path_1.default.join(entityDir, file);
|
|
414
|
-
|
|
580
|
+
// Backup the file before any modifications (unless dry-run)
|
|
581
|
+
if (!flags['dry-run'] && fileBackupManager) {
|
|
582
|
+
await fileBackupManager.backupFile(filePath);
|
|
583
|
+
}
|
|
584
|
+
// Parse JSON with line number tracking
|
|
585
|
+
const { content: fileContent, lineNumbers } = await this.parseJsonWithLineNumbers(filePath);
|
|
415
586
|
// Process templates in the loaded content
|
|
416
587
|
const processedContent = await syncEngine.processTemplates(fileContent, entityDir);
|
|
417
588
|
// Check if the file contains a single record or an array of records
|
|
418
589
|
const isArray = Array.isArray(processedContent);
|
|
419
590
|
const records = isArray ? processedContent : [processedContent];
|
|
420
|
-
totalRecords += records.length;
|
|
421
591
|
// Build and process defaults (including lookups)
|
|
422
592
|
const defaults = await syncEngine.buildDefaults(filePath, entityConfig);
|
|
423
593
|
// Process each record in the file
|
|
424
594
|
for (let i = 0; i < records.length; i++) {
|
|
425
595
|
const recordData = records[i];
|
|
426
596
|
// Process the record
|
|
427
|
-
const
|
|
597
|
+
const recordLineNumber = lineNumbers.get(i); // Get line number for this array index
|
|
598
|
+
const pushResult = await this.pushRecord(recordData, entityConfig.entity, path_1.default.dirname(filePath), file, defaults, syncEngine, flags['dry-run'], flags.verbose, isArray ? i : undefined, fileBackupManager, recordLineNumber);
|
|
428
599
|
if (!flags['dry-run']) {
|
|
429
|
-
|
|
430
|
-
|
|
600
|
+
// Don't count duplicates in stats
|
|
601
|
+
if (!pushResult.isDuplicate) {
|
|
602
|
+
if (pushResult.isNew) {
|
|
603
|
+
result.created++;
|
|
604
|
+
}
|
|
605
|
+
else if (pushResult.wasActuallyUpdated) {
|
|
606
|
+
result.updated++;
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
result.unchanged++;
|
|
610
|
+
}
|
|
431
611
|
}
|
|
432
|
-
|
|
433
|
-
|
|
612
|
+
// Add related entity stats
|
|
613
|
+
if (pushResult.relatedStats) {
|
|
614
|
+
result.created += pushResult.relatedStats.created;
|
|
615
|
+
result.updated += pushResult.relatedStats.updated;
|
|
616
|
+
result.unchanged += pushResult.relatedStats.unchanged;
|
|
617
|
+
// Debug logging for related entities
|
|
618
|
+
if (flags.verbose && pushResult.relatedStats.unchanged > 0) {
|
|
619
|
+
this.log(` Related entities: ${pushResult.relatedStats.unchanged} unchanged`);
|
|
620
|
+
}
|
|
434
621
|
}
|
|
435
622
|
}
|
|
436
|
-
spinner.text = `Processing records (${result.created + result.updated + result.errors}
|
|
623
|
+
spinner.text = `Processing records (${result.created + result.updated + result.unchanged + result.errors} processed)`;
|
|
437
624
|
}
|
|
438
625
|
// Write back the entire file if it's an array
|
|
439
626
|
if (isArray && !flags['dry-run']) {
|
|
@@ -446,21 +633,43 @@ class Push extends core_1.Command {
|
|
|
446
633
|
const fullErrorMessage = `Failed to process ${file}: ${errorMessage}`;
|
|
447
634
|
this.errors.push(fullErrorMessage);
|
|
448
635
|
this.error(fullErrorMessage, { exit: false });
|
|
636
|
+
this.log(' ⚠️ This error will cause all changes to be rolled back at the end of processing');
|
|
449
637
|
}
|
|
450
638
|
}
|
|
451
639
|
if (flags.verbose) {
|
|
452
|
-
spinner.succeed(`Processed ${
|
|
640
|
+
spinner.succeed(`Processed ${result.created + result.updated + result.unchanged} records from ${jsonFiles.length} files`);
|
|
453
641
|
}
|
|
454
642
|
else {
|
|
455
643
|
spinner.stop();
|
|
456
644
|
}
|
|
457
645
|
}
|
|
458
|
-
async pushRecord(recordData, entityName, baseDir, fileName, defaults, syncEngine, dryRun, verbose = false, arrayIndex) {
|
|
646
|
+
async pushRecord(recordData, entityName, baseDir, fileName, defaults, syncEngine, dryRun, verbose = false, arrayIndex, fileBackupManager, lineNumber) {
|
|
459
647
|
// Load or create entity
|
|
460
648
|
let entity = null;
|
|
461
649
|
let isNew = false;
|
|
462
650
|
if (recordData.primaryKey) {
|
|
463
651
|
entity = await syncEngine.loadEntity(entityName, recordData.primaryKey);
|
|
652
|
+
// Warn if record has primaryKey but wasn't found
|
|
653
|
+
if (!entity) {
|
|
654
|
+
const pkDisplay = Object.entries(recordData.primaryKey)
|
|
655
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
656
|
+
.join(', ');
|
|
657
|
+
// Load sync config to check autoCreateMissingRecords setting
|
|
658
|
+
const syncConfig = await (0, config_1.loadSyncConfig)(config_manager_1.configManager.getOriginalCwd());
|
|
659
|
+
const autoCreate = syncConfig?.push?.autoCreateMissingRecords ?? false;
|
|
660
|
+
if (!autoCreate) {
|
|
661
|
+
const fileRef = lineNumber ? `${fileName}:${lineNumber}` : fileName;
|
|
662
|
+
this.warn(`⚠️ Record not found: ${entityName} with primaryKey {${pkDisplay}} at ${fileRef}`);
|
|
663
|
+
this.warn(` To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`);
|
|
664
|
+
// Skip this record
|
|
665
|
+
return { isNew: false, wasActuallyUpdated: false, isDuplicate: false };
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
if (verbose) {
|
|
669
|
+
this.log(` Auto-creating missing ${entityName} record with primaryKey {${pkDisplay}}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
464
673
|
}
|
|
465
674
|
if (!entity) {
|
|
466
675
|
// New record
|
|
@@ -507,7 +716,7 @@ class Push extends core_1.Command {
|
|
|
507
716
|
try {
|
|
508
717
|
const processedValue = await syncEngine.processFieldValue(value, baseDir, null, null);
|
|
509
718
|
if (verbose) {
|
|
510
|
-
this.log(` Setting ${field}: ${
|
|
719
|
+
this.log(` Setting ${field}: ${this.formatFieldValue(value)} -> ${this.formatFieldValue(processedValue)}`);
|
|
511
720
|
}
|
|
512
721
|
entity[field] = processedValue;
|
|
513
722
|
}
|
|
@@ -521,7 +730,46 @@ class Push extends core_1.Command {
|
|
|
521
730
|
}
|
|
522
731
|
if (dryRun) {
|
|
523
732
|
this.log(`Would ${isNew ? 'create' : 'update'} ${entityName} record`);
|
|
524
|
-
return isNew;
|
|
733
|
+
return { isNew, wasActuallyUpdated: true, isDuplicate: false, relatedStats: undefined };
|
|
734
|
+
}
|
|
735
|
+
// Check for duplicate processing (but only for existing records that were loaded)
|
|
736
|
+
let isDuplicate = false;
|
|
737
|
+
if (!isNew && entity) {
|
|
738
|
+
const fullFilePath = path_1.default.join(baseDir, fileName);
|
|
739
|
+
isDuplicate = this.checkAndTrackRecord(entityName, entity, fullFilePath, arrayIndex, lineNumber);
|
|
740
|
+
}
|
|
741
|
+
// Check if the record is dirty before saving
|
|
742
|
+
let wasActuallyUpdated = false;
|
|
743
|
+
if (!isNew && entity.Dirty) {
|
|
744
|
+
// Record is dirty, get the changes
|
|
745
|
+
const changes = entity.GetChangesSinceLastSave();
|
|
746
|
+
const changeKeys = Object.keys(changes);
|
|
747
|
+
if (changeKeys.length > 0) {
|
|
748
|
+
wasActuallyUpdated = true;
|
|
749
|
+
// Get primary key info for display
|
|
750
|
+
const entityInfo = syncEngine.getEntityInfo(entityName);
|
|
751
|
+
const primaryKeyDisplay = [];
|
|
752
|
+
if (entityInfo) {
|
|
753
|
+
for (const pk of entityInfo.PrimaryKeys) {
|
|
754
|
+
primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
this.log(''); // Add newline before update output
|
|
758
|
+
this.log(`📝 Updating ${entityName} record:`);
|
|
759
|
+
if (primaryKeyDisplay.length > 0) {
|
|
760
|
+
this.log(` Primary Key: ${primaryKeyDisplay.join(', ')}`);
|
|
761
|
+
}
|
|
762
|
+
this.log(` Changes:`);
|
|
763
|
+
for (const fieldName of changeKeys) {
|
|
764
|
+
const field = entity.GetFieldByName(fieldName);
|
|
765
|
+
const oldValue = field ? field.OldValue : undefined;
|
|
766
|
+
const newValue = changes[fieldName];
|
|
767
|
+
this.log(` ${fieldName}: ${this.formatFieldValue(oldValue)} → ${this.formatFieldValue(newValue)}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
else if (isNew) {
|
|
772
|
+
wasActuallyUpdated = true;
|
|
525
773
|
}
|
|
526
774
|
// Save the record
|
|
527
775
|
const saved = await entity.Save();
|
|
@@ -534,9 +782,12 @@ class Push extends core_1.Command {
|
|
|
534
782
|
throw new Error(`Failed to save record: ${errors}`);
|
|
535
783
|
}
|
|
536
784
|
// Process related entities after saving parent
|
|
785
|
+
let relatedStats;
|
|
537
786
|
if (recordData.relatedEntities && !dryRun) {
|
|
538
|
-
|
|
539
|
-
|
|
787
|
+
const fullFilePath = path_1.default.join(baseDir, fileName);
|
|
788
|
+
relatedStats = await this.processRelatedEntities(recordData.relatedEntities, entity, entity, // root is same as parent for top level
|
|
789
|
+
baseDir, syncEngine, verbose, fileBackupManager, 1, // indentLevel
|
|
790
|
+
fullFilePath, arrayIndex);
|
|
540
791
|
}
|
|
541
792
|
// Update the local file with new primary key if created
|
|
542
793
|
if (isNew) {
|
|
@@ -548,6 +799,9 @@ class Push extends core_1.Command {
|
|
|
548
799
|
}
|
|
549
800
|
recordData.primaryKey = newPrimaryKey;
|
|
550
801
|
}
|
|
802
|
+
// Track the new record now that we have its primary key
|
|
803
|
+
const fullFilePath = path_1.default.join(baseDir, fileName);
|
|
804
|
+
this.checkAndTrackRecord(entityName, entity, fullFilePath, arrayIndex, lineNumber);
|
|
551
805
|
}
|
|
552
806
|
// Always update sync metadata
|
|
553
807
|
// This ensures related entities are persisted with their metadata
|
|
@@ -561,10 +815,11 @@ class Push extends core_1.Command {
|
|
|
561
815
|
const filePath = path_1.default.join(baseDir, fileName);
|
|
562
816
|
await fs_extra_1.default.writeJson(filePath, recordData, { spaces: 2 });
|
|
563
817
|
}
|
|
564
|
-
return isNew;
|
|
818
|
+
return { isNew, wasActuallyUpdated, isDuplicate, relatedStats };
|
|
565
819
|
}
|
|
566
|
-
async processRelatedEntities(relatedEntities, parentEntity, rootEntity, baseDir, syncEngine, verbose = false, indentLevel = 1) {
|
|
820
|
+
async processRelatedEntities(relatedEntities, parentEntity, rootEntity, baseDir, syncEngine, verbose = false, fileBackupManager, indentLevel = 1, parentFilePath, parentArrayIndex) {
|
|
567
821
|
const indent = ' '.repeat(indentLevel);
|
|
822
|
+
const stats = { created: 0, updated: 0, unchanged: 0 };
|
|
568
823
|
for (const [entityName, records] of Object.entries(relatedEntities)) {
|
|
569
824
|
if (verbose) {
|
|
570
825
|
this.log(`${indent}↳ Processing ${records.length} related ${entityName} records`);
|
|
@@ -576,6 +831,27 @@ class Push extends core_1.Command {
|
|
|
576
831
|
let isNew = false;
|
|
577
832
|
if (relatedRecord.primaryKey) {
|
|
578
833
|
entity = await syncEngine.loadEntity(entityName, relatedRecord.primaryKey);
|
|
834
|
+
// Warn if record has primaryKey but wasn't found
|
|
835
|
+
if (!entity) {
|
|
836
|
+
const pkDisplay = Object.entries(relatedRecord.primaryKey)
|
|
837
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
838
|
+
.join(', ');
|
|
839
|
+
// Load sync config to check autoCreateMissingRecords setting
|
|
840
|
+
const syncConfig = await (0, config_1.loadSyncConfig)(config_manager_1.configManager.getOriginalCwd());
|
|
841
|
+
const autoCreate = syncConfig?.push?.autoCreateMissingRecords ?? false;
|
|
842
|
+
if (!autoCreate) {
|
|
843
|
+
const fileRef = parentFilePath ? path_1.default.relative(config_manager_1.configManager.getOriginalCwd(), parentFilePath) : 'unknown';
|
|
844
|
+
this.warn(`${indent}⚠️ Related record not found: ${entityName} with primaryKey {${pkDisplay}} at ${fileRef}`);
|
|
845
|
+
this.warn(`${indent} To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`);
|
|
846
|
+
// Skip this record
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
if (verbose) {
|
|
851
|
+
this.log(`${indent} Auto-creating missing related ${entityName} record with primaryKey {${pkDisplay}}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
579
855
|
}
|
|
580
856
|
if (!entity) {
|
|
581
857
|
entity = await syncEngine.createEntityObject(entityName);
|
|
@@ -614,7 +890,7 @@ class Push extends core_1.Command {
|
|
|
614
890
|
try {
|
|
615
891
|
const processedValue = await syncEngine.processFieldValue(value, baseDir, parentEntity, rootEntity);
|
|
616
892
|
if (verbose) {
|
|
617
|
-
this.log(`${indent} Setting ${field}: ${
|
|
893
|
+
this.log(`${indent} Setting ${field}: ${this.formatFieldValue(value)} -> ${this.formatFieldValue(processedValue)}`);
|
|
618
894
|
}
|
|
619
895
|
entity[field] = processedValue;
|
|
620
896
|
}
|
|
@@ -626,6 +902,46 @@ class Push extends core_1.Command {
|
|
|
626
902
|
this.warn(`${indent} Field '${field}' does not exist on entity '${entityName}'`);
|
|
627
903
|
}
|
|
628
904
|
}
|
|
905
|
+
// Check for duplicate processing (but only for existing records that were loaded)
|
|
906
|
+
let isDuplicate = false;
|
|
907
|
+
if (!isNew && entity) {
|
|
908
|
+
// Use parent file path for related entities since they're defined in the parent's file
|
|
909
|
+
const relatedFilePath = parentFilePath || path_1.default.join(baseDir, 'unknown');
|
|
910
|
+
isDuplicate = this.checkAndTrackRecord(entityName, entity, relatedFilePath, parentArrayIndex);
|
|
911
|
+
}
|
|
912
|
+
// Check if the record is dirty before saving
|
|
913
|
+
let wasActuallyUpdated = false;
|
|
914
|
+
if (!isNew && entity.Dirty) {
|
|
915
|
+
// Record is dirty, get the changes
|
|
916
|
+
const changes = entity.GetChangesSinceLastSave();
|
|
917
|
+
const changeKeys = Object.keys(changes);
|
|
918
|
+
if (changeKeys.length > 0) {
|
|
919
|
+
wasActuallyUpdated = true;
|
|
920
|
+
// Get primary key info for display
|
|
921
|
+
const entityInfo = syncEngine.getEntityInfo(entityName);
|
|
922
|
+
const primaryKeyDisplay = [];
|
|
923
|
+
if (entityInfo) {
|
|
924
|
+
for (const pk of entityInfo.PrimaryKeys) {
|
|
925
|
+
primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
this.log(''); // Add newline before update output
|
|
929
|
+
this.log(`${indent}📝 Updating related ${entityName} record:`);
|
|
930
|
+
if (primaryKeyDisplay.length > 0) {
|
|
931
|
+
this.log(`${indent} Primary Key: ${primaryKeyDisplay.join(', ')}`);
|
|
932
|
+
}
|
|
933
|
+
this.log(`${indent} Changes:`);
|
|
934
|
+
for (const fieldName of changeKeys) {
|
|
935
|
+
const field = entity.GetFieldByName(fieldName);
|
|
936
|
+
const oldValue = field ? field.OldValue : undefined;
|
|
937
|
+
const newValue = changes[fieldName];
|
|
938
|
+
this.log(`${indent} ${fieldName}: ${this.formatFieldValue(oldValue)} → ${this.formatFieldValue(newValue)}`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
else if (isNew) {
|
|
943
|
+
wasActuallyUpdated = true;
|
|
944
|
+
}
|
|
629
945
|
// Save the related entity
|
|
630
946
|
const saved = await entity.Save();
|
|
631
947
|
if (!saved) {
|
|
@@ -636,9 +952,24 @@ class Push extends core_1.Command {
|
|
|
636
952
|
const errors = entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || 'Unknown error';
|
|
637
953
|
throw new Error(`Failed to save related ${entityName}: ${errors}`);
|
|
638
954
|
}
|
|
639
|
-
|
|
955
|
+
// Update stats - don't count duplicates
|
|
956
|
+
if (!isDuplicate) {
|
|
957
|
+
if (isNew) {
|
|
958
|
+
stats.created++;
|
|
959
|
+
}
|
|
960
|
+
else if (wasActuallyUpdated) {
|
|
961
|
+
stats.updated++;
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
stats.unchanged++;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (verbose && wasActuallyUpdated) {
|
|
640
968
|
this.log(`${indent} ✓ ${isNew ? 'Created' : 'Updated'} ${entityName} record`);
|
|
641
969
|
}
|
|
970
|
+
else if (verbose && !wasActuallyUpdated) {
|
|
971
|
+
this.log(`${indent} - No changes to ${entityName} record`);
|
|
972
|
+
}
|
|
642
973
|
// Update the related record with primary key and sync metadata
|
|
643
974
|
const entityInfo = syncEngine.getEntityInfo(entityName);
|
|
644
975
|
if (entityInfo) {
|
|
@@ -648,6 +979,9 @@ class Push extends core_1.Command {
|
|
|
648
979
|
for (const pk of entityInfo.PrimaryKeys) {
|
|
649
980
|
relatedRecord.primaryKey[pk.Name] = entity.Get(pk.Name);
|
|
650
981
|
}
|
|
982
|
+
// Track the new related entity now that we have its primary key
|
|
983
|
+
const relatedFilePath = parentFilePath || path_1.default.join(baseDir, 'unknown');
|
|
984
|
+
this.checkAndTrackRecord(entityName, entity, relatedFilePath, parentArrayIndex);
|
|
651
985
|
}
|
|
652
986
|
// Always update sync metadata
|
|
653
987
|
relatedRecord.sync = {
|
|
@@ -657,7 +991,11 @@ class Push extends core_1.Command {
|
|
|
657
991
|
}
|
|
658
992
|
// Process nested related entities if any
|
|
659
993
|
if (relatedRecord.relatedEntities) {
|
|
660
|
-
await this.processRelatedEntities(relatedRecord.relatedEntities, entity, rootEntity, baseDir, syncEngine, verbose, indentLevel + 1);
|
|
994
|
+
const nestedStats = await this.processRelatedEntities(relatedRecord.relatedEntities, entity, rootEntity, baseDir, syncEngine, verbose, fileBackupManager, indentLevel + 1, parentFilePath, parentArrayIndex);
|
|
995
|
+
// Accumulate nested stats
|
|
996
|
+
stats.created += nestedStats.created;
|
|
997
|
+
stats.updated += nestedStats.updated;
|
|
998
|
+
stats.unchanged += nestedStats.unchanged;
|
|
661
999
|
}
|
|
662
1000
|
}
|
|
663
1001
|
catch (error) {
|
|
@@ -665,6 +1003,128 @@ class Push extends core_1.Command {
|
|
|
665
1003
|
}
|
|
666
1004
|
}
|
|
667
1005
|
}
|
|
1006
|
+
return stats;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Generate a unique tracking key for a record based on entity name and primary key values
|
|
1010
|
+
*/
|
|
1011
|
+
generateRecordKey(entityName, entity) {
|
|
1012
|
+
const entityInfo = entity.EntityInfo;
|
|
1013
|
+
const primaryKeyValues = [];
|
|
1014
|
+
if (entityInfo && entityInfo.PrimaryKeys.length > 0) {
|
|
1015
|
+
for (const pk of entityInfo.PrimaryKeys) {
|
|
1016
|
+
const value = entity.Get(pk.Name);
|
|
1017
|
+
primaryKeyValues.push(`${pk.Name}:${value}`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return `${entityName}|${primaryKeyValues.join('|')}`;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Check if a record has already been processed and warn if duplicate
|
|
1024
|
+
*/
|
|
1025
|
+
checkAndTrackRecord(entityName, entity, filePath, arrayIndex, lineNumber) {
|
|
1026
|
+
const recordKey = this.generateRecordKey(entityName, entity);
|
|
1027
|
+
const existing = this.processedRecords.get(recordKey);
|
|
1028
|
+
if (existing) {
|
|
1029
|
+
const primaryKeyDisplay = entity.EntityInfo?.PrimaryKeys
|
|
1030
|
+
.map(pk => `${pk.Name}: ${entity.Get(pk.Name)}`)
|
|
1031
|
+
.join(', ') || 'unknown';
|
|
1032
|
+
// Format file location with clickable link for VSCode
|
|
1033
|
+
// Create maps with just the line numbers we have
|
|
1034
|
+
const currentLineMap = lineNumber ? new Map([[arrayIndex || 0, lineNumber]]) : undefined;
|
|
1035
|
+
const originalLineMap = existing.lineNumber ? new Map([[existing.arrayIndex || 0, existing.lineNumber]]) : undefined;
|
|
1036
|
+
const currentLocation = this.formatFileLocation(filePath, arrayIndex, currentLineMap);
|
|
1037
|
+
const originalLocation = this.formatFileLocation(existing.filePath, existing.arrayIndex, originalLineMap);
|
|
1038
|
+
this.warn(`⚠️ Duplicate record detected for ${entityName} (${primaryKeyDisplay})`);
|
|
1039
|
+
this.warn(` Current location: ${currentLocation}`);
|
|
1040
|
+
this.warn(` Original location: ${originalLocation}`);
|
|
1041
|
+
this.warn(` The duplicate update will proceed, but you should review your data for unintended duplicates.`);
|
|
1042
|
+
return true; // is duplicate
|
|
1043
|
+
}
|
|
1044
|
+
// Track the record with its source location
|
|
1045
|
+
this.processedRecords.set(recordKey, {
|
|
1046
|
+
filePath: filePath || 'unknown',
|
|
1047
|
+
arrayIndex,
|
|
1048
|
+
lineNumber
|
|
1049
|
+
});
|
|
1050
|
+
return false; // not duplicate
|
|
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
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Parse JSON file and track line numbers for array elements
|
|
1068
|
+
*/
|
|
1069
|
+
async parseJsonWithLineNumbers(filePath) {
|
|
1070
|
+
const fileText = await fs_extra_1.default.readFile(filePath, 'utf-8');
|
|
1071
|
+
const lines = fileText.split('\n');
|
|
1072
|
+
const lineNumbers = new Map();
|
|
1073
|
+
// Parse the JSON
|
|
1074
|
+
const content = JSON.parse(fileText);
|
|
1075
|
+
// If it's an array, try to find where each element starts
|
|
1076
|
+
if (Array.isArray(content)) {
|
|
1077
|
+
let inString = false;
|
|
1078
|
+
let bracketDepth = 0;
|
|
1079
|
+
let currentIndex = -1;
|
|
1080
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
1081
|
+
const line = lines[lineNum];
|
|
1082
|
+
// Simple tracking of string boundaries and bracket depth
|
|
1083
|
+
for (let i = 0; i < line.length; i++) {
|
|
1084
|
+
const char = line[i];
|
|
1085
|
+
const prevChar = i > 0 ? line[i - 1] : '';
|
|
1086
|
+
if (char === '"' && prevChar !== '\\') {
|
|
1087
|
+
inString = !inString;
|
|
1088
|
+
}
|
|
1089
|
+
if (!inString) {
|
|
1090
|
+
if (char === '{') {
|
|
1091
|
+
bracketDepth++;
|
|
1092
|
+
// If we're at depth 1 in the main array, this is a new object
|
|
1093
|
+
if (bracketDepth === 1 && line.trim().startsWith('{')) {
|
|
1094
|
+
currentIndex++;
|
|
1095
|
+
lineNumbers.set(currentIndex, lineNum + 1); // 1-based line numbers
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
else if (char === '}') {
|
|
1099
|
+
bracketDepth--;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return { content, lineNumbers };
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Format file location with clickable link for VSCode
|
|
1109
|
+
*/
|
|
1110
|
+
formatFileLocation(filePath, arrayIndex, lineNumbers) {
|
|
1111
|
+
if (!filePath || filePath === 'unknown') {
|
|
1112
|
+
return 'unknown';
|
|
1113
|
+
}
|
|
1114
|
+
// Get absolute path for better VSCode integration
|
|
1115
|
+
const absolutePath = path_1.default.resolve(filePath);
|
|
1116
|
+
// Try to get actual line number from our tracking
|
|
1117
|
+
let lineNumber = 1;
|
|
1118
|
+
if (arrayIndex !== undefined && lineNumbers && lineNumbers.has(arrayIndex)) {
|
|
1119
|
+
lineNumber = lineNumbers.get(arrayIndex);
|
|
1120
|
+
}
|
|
1121
|
+
else if (arrayIndex !== undefined) {
|
|
1122
|
+
// Fallback estimation if we don't have actual line numbers
|
|
1123
|
+
lineNumber = 2 + (arrayIndex * 15);
|
|
1124
|
+
}
|
|
1125
|
+
// Create clickable file path for VSCode - format: file:line
|
|
1126
|
+
// VSCode will make this clickable in the terminal
|
|
1127
|
+
return `${absolutePath}:${lineNumber}`;
|
|
668
1128
|
}
|
|
669
1129
|
}
|
|
670
1130
|
exports.default = Push;
|