@memberjunction/metadata-sync 6.2.0-edge.4 → 6.2.0-edge.5

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 (122) hide show
  1. package/README.md +115 -51
  2. package/dist/config.d.ts +5 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/RecordProcessor.d.ts +98 -0
  10. package/dist/lib/RecordProcessor.d.ts.map +1 -1
  11. package/dist/lib/RecordProcessor.js +424 -22
  12. package/dist/lib/RecordProcessor.js.map +1 -1
  13. package/dist/lib/RelatedEntityHandler.d.ts +4 -1
  14. package/dist/lib/RelatedEntityHandler.d.ts.map +1 -1
  15. package/dist/lib/RelatedEntityHandler.js +49 -11
  16. package/dist/lib/RelatedEntityHandler.js.map +1 -1
  17. package/dist/lib/created-record-key.d.ts +34 -0
  18. package/dist/lib/created-record-key.d.ts.map +1 -0
  19. package/dist/lib/created-record-key.js +46 -0
  20. package/dist/lib/created-record-key.js.map +1 -0
  21. package/dist/lib/deletion-auditor.d.ts.map +1 -1
  22. package/dist/lib/deletion-auditor.js +33 -2
  23. package/dist/lib/deletion-auditor.js.map +1 -1
  24. package/dist/lib/entity-field-names.d.ts +11 -0
  25. package/dist/lib/entity-field-names.d.ts.map +1 -0
  26. package/dist/lib/entity-field-names.js +15 -0
  27. package/dist/lib/entity-field-names.js.map +1 -0
  28. package/dist/lib/env-derived-fields.d.ts +23 -0
  29. package/dist/lib/env-derived-fields.d.ts.map +1 -0
  30. package/dist/lib/env-derived-fields.js +53 -0
  31. package/dist/lib/env-derived-fields.js.map +1 -0
  32. package/dist/lib/env-display.d.ts +54 -0
  33. package/dist/lib/env-display.d.ts.map +1 -0
  34. package/dist/lib/env-display.js +156 -0
  35. package/dist/lib/env-display.js.map +1 -0
  36. package/dist/lib/env-reference.d.ts +104 -0
  37. package/dist/lib/env-reference.d.ts.map +1 -0
  38. package/dist/lib/env-reference.js +241 -0
  39. package/dist/lib/env-reference.js.map +1 -0
  40. package/dist/lib/env-sql-redactor.d.ts +74 -0
  41. package/dist/lib/env-sql-redactor.d.ts.map +1 -0
  42. package/dist/lib/env-sql-redactor.js +297 -0
  43. package/dist/lib/env-sql-redactor.js.map +1 -0
  44. package/dist/lib/existing-record-index.d.ts +70 -0
  45. package/dist/lib/existing-record-index.d.ts.map +1 -0
  46. package/dist/lib/existing-record-index.js +158 -0
  47. package/dist/lib/existing-record-index.js.map +1 -0
  48. package/dist/lib/ignore-directories.d.ts +10 -0
  49. package/dist/lib/ignore-directories.d.ts.map +1 -0
  50. package/dist/lib/ignore-directories.js +12 -0
  51. package/dist/lib/ignore-directories.js.map +1 -0
  52. package/dist/lib/json-record-lines.d.ts +17 -0
  53. package/dist/lib/json-record-lines.d.ts.map +1 -0
  54. package/dist/lib/json-record-lines.js +170 -0
  55. package/dist/lib/json-record-lines.js.map +1 -0
  56. package/dist/lib/json-subproperty-externalization.d.ts.map +1 -1
  57. package/dist/lib/json-subproperty-externalization.js +3 -1
  58. package/dist/lib/json-subproperty-externalization.js.map +1 -1
  59. package/dist/lib/lookup-reference.d.ts +38 -0
  60. package/dist/lib/lookup-reference.d.ts.map +1 -0
  61. package/dist/lib/lookup-reference.js +62 -0
  62. package/dist/lib/lookup-reference.js.map +1 -0
  63. package/dist/lib/provider-utils.d.ts.map +1 -1
  64. package/dist/lib/provider-utils.js +2 -4
  65. package/dist/lib/provider-utils.js.map +1 -1
  66. package/dist/lib/pull-option-overrides.d.ts +24 -0
  67. package/dist/lib/pull-option-overrides.d.ts.map +1 -0
  68. package/dist/lib/pull-option-overrides.js +39 -0
  69. package/dist/lib/pull-option-overrides.js.map +1 -0
  70. package/dist/lib/push-directories.d.ts +42 -0
  71. package/dist/lib/push-directories.d.ts.map +1 -0
  72. package/dist/lib/push-directories.js +71 -0
  73. package/dist/lib/push-directories.js.map +1 -0
  74. package/dist/lib/push-when.d.ts +38 -0
  75. package/dist/lib/push-when.d.ts.map +1 -0
  76. package/dist/lib/push-when.js +84 -0
  77. package/dist/lib/push-when.js.map +1 -0
  78. package/dist/lib/record-dependency-analyzer.d.ts +2 -0
  79. package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
  80. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  81. package/dist/lib/record-file-glob.d.ts +11 -0
  82. package/dist/lib/record-file-glob.d.ts.map +1 -0
  83. package/dist/lib/record-file-glob.js +10 -0
  84. package/dist/lib/record-file-glob.js.map +1 -0
  85. package/dist/lib/reference-parser.d.ts +3 -2
  86. package/dist/lib/reference-parser.d.ts.map +1 -1
  87. package/dist/lib/reference-parser.js +27 -27
  88. package/dist/lib/reference-parser.js.map +1 -1
  89. package/dist/lib/sync-engine.d.ts +46 -4
  90. package/dist/lib/sync-engine.d.ts.map +1 -1
  91. package/dist/lib/sync-engine.js +215 -59
  92. package/dist/lib/sync-engine.js.map +1 -1
  93. package/dist/lib/sync-state-manager.d.ts +7 -3
  94. package/dist/lib/sync-state-manager.d.ts.map +1 -1
  95. package/dist/lib/sync-state-manager.js +19 -4
  96. package/dist/lib/sync-state-manager.js.map +1 -1
  97. package/dist/lib/watch-file-filter.d.ts +31 -0
  98. package/dist/lib/watch-file-filter.d.ts.map +1 -0
  99. package/dist/lib/watch-file-filter.js +64 -0
  100. package/dist/lib/watch-file-filter.js.map +1 -0
  101. package/dist/plugins/index.d.ts.map +1 -1
  102. package/dist/plugins/index.js +44 -14
  103. package/dist/plugins/index.js.map +1 -1
  104. package/dist/services/PullService.d.ts +41 -1
  105. package/dist/services/PullService.d.ts.map +1 -1
  106. package/dist/services/PullService.js +261 -101
  107. package/dist/services/PullService.js.map +1 -1
  108. package/dist/services/PushService.d.ts +59 -0
  109. package/dist/services/PushService.d.ts.map +1 -1
  110. package/dist/services/PushService.js +435 -173
  111. package/dist/services/PushService.js.map +1 -1
  112. package/dist/services/ValidationService.d.ts +43 -8
  113. package/dist/services/ValidationService.d.ts.map +1 -1
  114. package/dist/services/ValidationService.js +338 -127
  115. package/dist/services/ValidationService.js.map +1 -1
  116. package/dist/services/WatchService.d.ts +26 -0
  117. package/dist/services/WatchService.d.ts.map +1 -1
  118. package/dist/services/WatchService.js +210 -75
  119. package/dist/services/WatchService.js.map +1 -1
  120. package/dist/types/validation.d.ts +6 -0
  121. package/dist/types/validation.d.ts.map +1 -1
  122. package/package.json +17 -16
@@ -1,10 +1,19 @@
1
1
  import { Metadata, RunView } from '@memberjunction/core';
2
2
  import { UUIDsEqual } from '@memberjunction/global';
3
3
  import * as fs from 'fs';
4
+ import fse from 'fs-extra';
4
5
  import * as path from 'path';
5
6
  import { minimatch } from 'minimatch';
7
+ import fastGlob from 'fast-glob';
8
+ import { RecordFieldNames } from '../lib/entity-field-names.js';
9
+ import { RECORD_FILE_GLOB_OPTIONS } from '../lib/record-file-glob.js';
6
10
  import { GetSystemUser } from '../lib/provider-utils.js';
11
+ import { IsIgnoredDirectoryName } from '../lib/ignore-directories.js';
12
+ import { MapJsonStartLines } from '../lib/json-record-lines.js';
7
13
  import { ParseMetadataReference } from '../lib/reference-parser.js';
14
+ import { ParseEnvReference } from '../lib/env-reference.js';
15
+ import { LOOKUP_OPTIONAL_MESSAGE } from '../lib/lookup-reference.js';
16
+ import { EvaluatePushWhen, PushWhenError } from '../lib/push-when.js';
8
17
  import { METADATA_KEYWORDS, IsMetadataKeyword, ExtractKeywordValue } from '../constants/metadata-keywords.js';
9
18
  import { ResolveCollectionRelationship } from '@memberjunction/record-graph';
10
19
  export class ValidationService {
@@ -14,6 +23,10 @@ export class ValidationService {
14
23
  this.entityDependencies = new Map();
15
24
  this.processedEntities = new Set();
16
25
  this.claimedPrimaryKeys = new Map();
26
+ /** 1-based start line of each record object read from a record file. */
27
+ this.recordStartLines = new WeakMap();
28
+ /** The entity folder being validated. Push resolves record `@file:` references from it. */
29
+ this.entityDir = '';
17
30
  this.userRoleCache = new Map();
18
31
  this.metadata = new Metadata(); // global-provider-ok: metadata sync operates on the configured provider only
19
32
  this.options = {
@@ -48,13 +61,26 @@ export class ValidationService {
48
61
  if (config.userRoleValidation?.enabled) {
49
62
  await this.loadUserRoles();
50
63
  }
51
- const directories = await this.getDirectoriesInOrder(dir, config);
64
+ if (!config.entity && config.pushWhen !== undefined) {
65
+ this.addError({
66
+ type: 'entity',
67
+ severity: 'error',
68
+ file: configPath,
69
+ message: 'pushWhen is only allowed in an entity folder .mj-sync.json',
70
+ suggestion: 'Move pushWhen into the .mj-sync.json of each folder it should gate',
71
+ });
72
+ }
73
+ // A config with an entity means `dir` is one entity folder, unless the caller treats it as a root
74
+ const isEntityFolder = !!config.entity && !this.options.ValidateChildFoldersOnly;
75
+ const candidates = isEntityFolder ? [''] : await this.getDirectoriesInOrder(dir, config);
76
+ const directories = candidates.filter((subDir) => this.pushWhenAllows(path.join(dir, subDir)));
52
77
  let totalFiles = 0;
53
78
  let totalEntities = 0;
54
79
  const fileResults = new Map();
55
80
  for (const subDir of directories) {
56
81
  const subDirPath = path.join(dir, subDir);
57
82
  const result = await this.validateEntityDirectory(subDirPath);
83
+ this.warnNestedPushWhen(subDirPath);
58
84
  if (result) {
59
85
  totalFiles += result.files;
60
86
  totalEntities += result.entities;
@@ -63,7 +89,6 @@ export class ValidationService {
63
89
  }
64
90
  }
65
91
  }
66
- // Validate dependency order
67
92
  await this.validateDependencyOrder(directories);
68
93
  return {
69
94
  isValid: this.errors.length === 0,
@@ -78,6 +103,35 @@ export class ValidationService {
78
103
  },
79
104
  };
80
105
  }
106
+ /** Warns about each `.mj-sync.json` below `dir` that has `pushWhen`; push over a parent folder ignores it. */
107
+ warnNestedPushWhen(dir) {
108
+ const configPaths = fastGlob.sync('*/**/.mj-sync.json', {
109
+ cwd: dir,
110
+ absolute: true,
111
+ dot: true,
112
+ ignore: ['**/node_modules/**'],
113
+ suppressErrors: true,
114
+ });
115
+ for (const configPath of configPaths) {
116
+ let config;
117
+ try {
118
+ config = fse.readJsonSync(configPath);
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ if (config === null || typeof config !== 'object' || Array.isArray(config) || !('pushWhen' in config)) {
124
+ continue;
125
+ }
126
+ this.addWarning({
127
+ type: 'validation',
128
+ severity: 'warning',
129
+ file: configPath,
130
+ message: 'pushWhen in a nested folder has no effect when push runs over a parent folder',
131
+ suggestion: 'Move the folder directly under the metadata root, or push it with --dir',
132
+ });
133
+ }
134
+ }
81
135
  /** @deprecated Use {@link ValidateDirectory}. */
82
136
  async validateDirectory(dir) {
83
137
  return this.ValidateDirectory(dir);
@@ -86,31 +140,12 @@ export class ValidationService {
86
140
  * Validates a single entity directory
87
141
  */
88
142
  async validateEntityDirectory(dir) {
89
- // Check for .mj-folder.json first (new format)
90
- let configPath = path.join(dir, '.mj-folder.json');
91
- let config;
92
- if (fs.existsSync(configPath)) {
93
- config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
94
- // .mj-folder.json uses entityName field
95
- if (!config.entityName) {
96
- this.addError({
97
- type: 'validation',
98
- severity: 'error',
99
- file: configPath,
100
- message: 'Missing entityName field in .mj-folder.json',
101
- });
102
- return null;
103
- }
104
- config.entity = config.entityName; // Normalize to entity field
105
- }
106
- else {
107
- // Fall back to .mj-sync.json (legacy format)
108
- configPath = path.join(dir, '.mj-sync.json');
109
- if (!fs.existsSync(configPath)) {
110
- return null;
111
- }
112
- config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
143
+ // The folder's entity comes from .mj-sync.json; .mj-folder.json holds only defaults
144
+ const configPath = path.join(dir, '.mj-sync.json');
145
+ if (!fs.existsSync(configPath)) {
146
+ return null;
113
147
  }
148
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
114
149
  // Validate entity name exists
115
150
  if (!config.entity || config.entity.trim() === '') {
116
151
  this.addError({
@@ -121,7 +156,8 @@ export class ValidationService {
121
156
  });
122
157
  return null;
123
158
  }
124
- const entityInfo = this.metadata.EntityByName(config.entity);
159
+ const entityConfig = { ...config, entity: config.entity };
160
+ const entityInfo = this.metadata.EntityByName(entityConfig.entity);
125
161
  if (!entityInfo) {
126
162
  this.addError({
127
163
  type: 'entity',
@@ -145,11 +181,12 @@ export class ValidationService {
145
181
  }
146
182
  }
147
183
  }
184
+ this.entityDir = path.resolve(dir);
148
185
  const files = await this.getMatchingFiles(dir, config.filePattern);
149
186
  let totalEntities = 0;
150
187
  const fileResults = new Map();
151
188
  for (const file of files) {
152
- const result = await this.validateFile(file, entityInfo, config);
189
+ const result = await this.validateFile(file, entityInfo, entityConfig);
153
190
  if (result) {
154
191
  totalEntities += result.entityCount;
155
192
  fileResults.set(file, result);
@@ -161,11 +198,11 @@ export class ValidationService {
161
198
  * Validates a single metadata file
162
199
  */
163
200
  async validateFile(filePath, entityInfo, config) {
164
- const fileErrors = [];
165
201
  let entityCount = 0;
166
202
  try {
167
- const content = fs.readFileSync(filePath, 'utf8');
203
+ const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
168
204
  const data = JSON.parse(content);
205
+ this.recordStartLines = MapJsonStartLines(content, data);
169
206
  const entities = Array.isArray(data) ? data : [data];
170
207
  entityCount = entities.length;
171
208
  for (const entityData of entities) {
@@ -173,7 +210,7 @@ export class ValidationService {
173
210
  }
174
211
  }
175
212
  catch (error) {
176
- fileErrors.push({
213
+ this.addError({
177
214
  type: 'entity',
178
215
  severity: 'error',
179
216
  file: filePath,
@@ -222,24 +259,8 @@ export class ValidationService {
222
259
  });
223
260
  }
224
261
  }
225
- // Check for double ownership across metadata files (§6)
226
- if (entityData.primaryKey && Object.keys(entityData.primaryKey).length > 0) {
227
- const claimKey = `${entityInfo.Name}:${JSON.stringify(entityData.primaryKey)}`;
228
- const existingFile = this.claimedPrimaryKeys.get(claimKey);
229
- if (existingFile && existingFile !== filePath) {
230
- this.addError({
231
- type: 'field',
232
- severity: 'error',
233
- entity: entityInfo.Name,
234
- file: filePath,
235
- message: `Record with primaryKey ${JSON.stringify(entityData.primaryKey)} on entity "${entityInfo.Name}" is already claimed by "${existingFile}". Double ownership is not allowed.`,
236
- suggestion: 'Ensure records are owned by only one sync file or collection',
237
- });
238
- }
239
- else {
240
- this.claimedPrimaryKeys.set(claimKey, filePath);
241
- }
242
- }
262
+ this.validatePrimaryKeyEnvReferences(entityData.primaryKey, entityInfo.Name, filePath);
263
+ this.checkRecordClaim(entityData, entityInfo.Name, filePath);
243
264
  // Skip validation for deletion records - they don't need field validation or reference checks
244
265
  if (entityData.deleteRecord?.delete === true) {
245
266
  // Only validate that primaryKey exists for deletion records
@@ -285,6 +306,7 @@ export class ValidationService {
285
306
  // block) so the required-field best-practice check can be limited to NEW
286
307
  // records — an existing record already holds the value in the DB, so a
287
308
  // missing NOT-NULL field in the file is not a problem and would only be noise.
309
+ this.reportTemplateValues(entityData.fields, entityData, entityInfo.Name, filePath);
288
310
  await this.validateFields(entityData.fields, entityInfo, filePath, parentContext, !!entityData.primaryKey, ownerContext);
289
311
  // Track dependencies
290
312
  this.trackEntityDependencies(entityData, entityInfo.Name, filePath);
@@ -360,9 +382,10 @@ export class ValidationService {
360
382
  */
361
383
  async validateFields(fields, entityInfo, filePath, parentContext, isExistingRecord = false, ownerContext) {
362
384
  const entityFields = entityInfo.Fields;
363
- const fieldMap = new Map(entityFields.map((f) => [f.Name, f]));
385
+ // Field names match the entity's fields without regard to case, as push matches them
386
+ const fieldMap = new Map(entityFields.map((f) => [f.Name.toLowerCase(), f]));
364
387
  for (const [fieldName, fieldValue] of Object.entries(fields)) {
365
- const fieldInfo = fieldMap.get(fieldName);
388
+ const fieldInfo = fieldMap.get(fieldName.toLowerCase());
366
389
  if (!fieldInfo) {
367
390
  // Check if this might be a virtual property (getter/setter)
368
391
  try {
@@ -399,8 +422,24 @@ export class ValidationService {
399
422
  continue;
400
423
  }
401
424
  }
402
- // Check if field is settable (not system field)
403
- if (fieldInfo.ReadOnly || fieldName.startsWith('__mj_')) {
425
+ const isSystemField = fieldInfo.Name.startsWith('__mj_');
426
+ // A read-only field that push ignores (such as a joined display field that pull writes) is a warning
427
+ if (fieldInfo.ReadOnly && !fieldInfo.IsPrimaryKey && !isSystemField) {
428
+ const pullAdvice = fieldInfo.IsVirtual
429
+ ? 'set pull.ignoreVirtualFields: true to stop pull writing it'
430
+ : 'add it to pull.excludeFields to stop pull writing it';
431
+ this.addWarning({
432
+ type: 'validation',
433
+ severity: 'warning',
434
+ entity: entityInfo.Name,
435
+ field: fieldName,
436
+ file: filePath,
437
+ message: `Field "${fieldName}" is read-only: push ignores it; ${pullAdvice}`,
438
+ });
439
+ continue;
440
+ }
441
+ // A system field or the primary key cannot be set in fields
442
+ if (fieldInfo.ReadOnly || isSystemField) {
404
443
  this.addError({
405
444
  type: 'field',
406
445
  severity: 'error',
@@ -422,9 +461,10 @@ export class ValidationService {
422
461
  // pure noise that masks genuine warnings. New records (no primaryKey) still
423
462
  // get the full check since their required fields must be supplied to insert.
424
463
  if (this.options.checkBestPractices && !isExistingRecord) {
464
+ const recordFields = new RecordFieldNames(fields);
425
465
  for (const field of entityFields) {
426
466
  // Skip if field allows null or has a value already (use 'in' to handle falsy values like 0, false, "")
427
- if (field.AllowsNull || field.Name in fields) {
467
+ if (field.AllowsNull || recordFields.Has(field.Name)) {
428
468
  continue;
429
469
  }
430
470
  // Skip if field has a default value
@@ -577,6 +617,107 @@ export class ValidationService {
577
617
  }
578
618
  }
579
619
  }
620
+ /** Reports each `@template:` value in a record's fields. Push saves that text unchanged instead of loading a file. */
621
+ reportTemplateValues(fields, record, entityName, filePath) {
622
+ const line = this.recordStartLines.get(record);
623
+ for (const [fieldName, value] of Object.entries(fields)) {
624
+ if (typeof value === 'string' && value.startsWith(METADATA_KEYWORDS.TEMPLATE)) {
625
+ this.addTemplateError(value, entityName, fieldName, filePath, line);
626
+ }
627
+ }
628
+ }
629
+ addTemplateError(value, entityName, fieldName, filePath, line) {
630
+ const location = line === undefined ? filePath : `${filePath}:${line}`;
631
+ this.addError({
632
+ type: 'reference',
633
+ severity: 'error',
634
+ entity: entityName,
635
+ field: fieldName,
636
+ file: filePath,
637
+ message: `@template: is not supported by mj sync push; push would save the literal text "${value}" to ${entityName}.${fieldName} ` +
638
+ `at ${location}. Use @file: instead.`,
639
+ });
640
+ }
641
+ /**
642
+ * Records an entry for its entity and primary key. Several entries may describe the same record,
643
+ * but two entries that set the same field to different raw values give an error.
644
+ * A key that holds an `@parent:`, `@owner:` or `@root:` reference anywhere, or an object value, is not
645
+ * recorded.
646
+ */
647
+ checkRecordClaim(entityData, entityName, filePath) {
648
+ if (!entityData.primaryKey || Object.keys(entityData.primaryKey).length === 0 || this.hasRelativeKeyValue(entityData.primaryKey)) {
649
+ return;
650
+ }
651
+ const fields = entityData.fields;
652
+ const claim = {
653
+ Path: filePath,
654
+ Line: this.recordStartLines.get(entityData),
655
+ Fields: fields && typeof fields === 'object' && !Array.isArray(fields) ? fields : {},
656
+ };
657
+ const claimKey = `${entityName}:${JSON.stringify(entityData.primaryKey)}`;
658
+ const claims = this.claimedPrimaryKeys.get(claimKey) ?? [];
659
+ for (const earlier of claims) {
660
+ const conflictingFields = Object.keys(claim.Fields).filter((field) => Object.prototype.hasOwnProperty.call(earlier.Fields, field) && !this.jsonValuesEqual(earlier.Fields[field], claim.Fields[field]));
661
+ if (conflictingFields.length > 0) {
662
+ this.addClaimConflictError(entityName, entityData.primaryKey, conflictingFields, earlier, claim);
663
+ }
664
+ }
665
+ claims.push(claim);
666
+ this.claimedPrimaryKeys.set(claimKey, claims);
667
+ }
668
+ /**
669
+ * True when a key value can depend on the parent, owner or root record, so equal key text can name
670
+ * different records: it holds one of these references (also inside a `@lookup:`), or it is an object.
671
+ */
672
+ hasRelativeKeyValue(primaryKey) {
673
+ const relativePrefixes = [METADATA_KEYWORDS.PARENT, METADATA_KEYWORDS.OWNER, METADATA_KEYWORDS.ROOT];
674
+ return Object.values(primaryKey).some((value) => (typeof value === 'object' && value !== null) ||
675
+ (typeof value === 'string' && relativePrefixes.some((prefix) => value.includes(prefix))));
676
+ }
677
+ /** Adds the error for two entries of one record that set fields to different values. */
678
+ addClaimConflictError(entityName, primaryKey, conflictingFields, earlier, later) {
679
+ const earlierLocation = this.formatClaimLocation(earlier);
680
+ const laterLocation = this.formatClaimLocation(later);
681
+ const fieldText = conflictingFields
682
+ .map((field) => `field "${field}" is ${this.formatClaimValue(earlier.Fields[field])} at ${earlierLocation} and ${this.formatClaimValue(later.Fields[field])} at ${laterLocation}`)
683
+ .join('; ');
684
+ this.addError({
685
+ type: 'field',
686
+ severity: 'error',
687
+ entity: entityName,
688
+ field: conflictingFields.length === 1 ? conflictingFields[0] : undefined,
689
+ file: later.Path,
690
+ message: `Conflicting values for ${entityName} record ${JSON.stringify(primaryKey)}: ${fieldText}. ` +
691
+ 'Make both entries set the same value, or set the field in one entry only.',
692
+ });
693
+ }
694
+ formatClaimLocation(claim) {
695
+ return claim.Line === undefined ? claim.Path : `${claim.Path}:${claim.Line}`;
696
+ }
697
+ /** A raw field value as JSON text, cut to 80 characters. */
698
+ formatClaimValue(value) {
699
+ const text = JSON.stringify(value) ?? String(value);
700
+ return text.length > 80 ? `${text.slice(0, 77)}...` : text;
701
+ }
702
+ /** Deep equality of two parsed JSON values. Object key order does not matter. */
703
+ jsonValuesEqual(left, right) {
704
+ if (left === right) {
705
+ return true;
706
+ }
707
+ if (Array.isArray(left) || Array.isArray(right)) {
708
+ return (Array.isArray(left) &&
709
+ Array.isArray(right) &&
710
+ left.length === right.length &&
711
+ left.every((item, index) => this.jsonValuesEqual(item, right[index])));
712
+ }
713
+ if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') {
714
+ return false;
715
+ }
716
+ const leftEntries = Object.entries(left);
717
+ const rightValues = new Map(Object.entries(right));
718
+ return (leftEntries.length === rightValues.size &&
719
+ leftEntries.every(([key, value]) => rightValues.has(key) && this.jsonValuesEqual(value, rightValues.get(key))));
720
+ }
580
721
  /**
581
722
  * Check if a string is actually a MetadataSync reference (not just any @ string)
582
723
  */
@@ -601,13 +742,13 @@ export class ValidationService {
601
742
  }
602
743
  switch (parsed.type) {
603
744
  case METADATA_KEYWORDS.FILE:
604
- await this.validateFileReference(parsed.value, filePath, entityInfo.Name, fieldInfo.Name);
745
+ await this.validateFileReference(parsed.value, filePath, this.entityDir, entityInfo.Name, fieldInfo.Name);
605
746
  break;
606
747
  case METADATA_KEYWORDS.LOOKUP:
607
748
  await this.validateLookupReference(parsed, filePath, entityInfo.Name, fieldInfo.Name);
608
749
  break;
609
750
  case METADATA_KEYWORDS.TEMPLATE:
610
- await this.validateTemplateReference(parsed.value, filePath, entityInfo.Name, fieldInfo.Name);
751
+ // Reported by reportTemplateValues: push does not load @template: files
611
752
  break;
612
753
  case METADATA_KEYWORDS.OWNER:
613
754
  this.validateOwnerReference(parsed.value, ownerContext, filePath, entityInfo.Name, fieldInfo.Name);
@@ -630,11 +771,11 @@ export class ValidationService {
630
771
  return ParseMetadataReference(reference);
631
772
  }
632
773
  /**
633
- * Validates @file: references
774
+ * Validates @file: references. `baseDir` is the folder push resolves the path from: the entity
775
+ * folder for a record field, or the folder of the JSON file that holds the reference.
634
776
  */
635
- async validateFileReference(filePath, sourceFile, entityName, fieldName, visitedFiles) {
636
- const dir = path.dirname(sourceFile);
637
- const resolvedPath = path.resolve(dir, filePath);
777
+ async validateFileReference(filePath, sourceFile, baseDir, entityName, fieldName, visitedFiles) {
778
+ const resolvedPath = path.resolve(baseDir, filePath);
638
779
  // Initialize visited files set if not provided (for circular reference detection)
639
780
  const visited = visitedFiles || new Set();
640
781
  // Check for circular references
@@ -707,6 +848,17 @@ export class ValidationService {
707
848
  * Validates @lookup: references
708
849
  */
709
850
  async validateLookupReference(parsed, sourceFile, entityName, fieldName) {
851
+ if (parsed.UnknownFlags?.includes('optional')) {
852
+ this.addError({
853
+ type: 'reference',
854
+ severity: 'error',
855
+ entity: entityName,
856
+ field: fieldName,
857
+ file: sourceFile,
858
+ message: LOOKUP_OPTIONAL_MESSAGE,
859
+ suggestion: 'Remove ?optional. Use @env:VAR?optional only as a whole field value.',
860
+ });
861
+ }
710
862
  const lookupEntity = this.metadata.EntityByName(parsed.entity);
711
863
  if (!lookupEntity) {
712
864
  this.addError({
@@ -755,40 +907,6 @@ export class ValidationService {
755
907
  // Track dependency
756
908
  this.addEntityDependency(entityName, parsed.entity);
757
909
  }
758
- /**
759
- * Validates @template: references
760
- */
761
- async validateTemplateReference(templatePath, sourceFile, entityName, fieldName) {
762
- const dir = path.dirname(sourceFile);
763
- const resolvedPath = path.resolve(dir, templatePath);
764
- if (!fs.existsSync(resolvedPath)) {
765
- this.addError({
766
- type: 'reference',
767
- severity: 'error',
768
- entity: entityName,
769
- field: fieldName,
770
- file: sourceFile,
771
- message: `Template file not found: "${templatePath}"`,
772
- suggestion: `Create template at: ${resolvedPath}`,
773
- });
774
- return;
775
- }
776
- // Validate template is valid JSON
777
- try {
778
- JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
779
- }
780
- catch (error) {
781
- this.addError({
782
- type: 'reference',
783
- severity: 'error',
784
- entity: entityName,
785
- field: fieldName,
786
- file: sourceFile,
787
- message: `Template file is not valid JSON: "${templatePath}"`,
788
- details: error instanceof Error ? error.message : String(error),
789
- });
790
- }
791
- }
792
910
  /**
793
911
  * Validates @parent: references
794
912
  */
@@ -936,10 +1054,12 @@ export class ValidationService {
936
1054
  * Get directories in order based on config
937
1055
  */
938
1056
  async getDirectoriesInOrder(rootDir, config) {
1057
+ // The folders push visits: no dot folders, none that match ignoreDirectories
939
1058
  const allDirs = fs
940
1059
  .readdirSync(rootDir)
941
1060
  .filter((f) => fs.statSync(path.join(rootDir, f)).isDirectory())
942
- .filter((d) => !d.startsWith('.'));
1061
+ .filter((d) => !d.startsWith('.'))
1062
+ .filter((d) => !IsIgnoredDirectoryName(d, config.ignoreDirectories));
943
1063
  let orderedDirs;
944
1064
  if (config.directoryOrder && Array.isArray(config.directoryOrder)) {
945
1065
  const ordered = config.directoryOrder.filter((d) => allDirs.includes(d));
@@ -952,6 +1072,61 @@ export class ValidationService {
952
1072
  // Apply include/exclude filters if specified
953
1073
  return this.applyDirectoryFilters(orderedDirs);
954
1074
  }
1075
+ /**
1076
+ * Checks a folder's pushWhen and reports a malformed value or an unreadable
1077
+ * .mj-sync.json as an error. Returns false when the config cannot be read, or when
1078
+ * HonorPushWhen is set and push would skip the folder.
1079
+ */
1080
+ pushWhenAllows(dirPath) {
1081
+ const configPath = path.join(dirPath, '.mj-sync.json');
1082
+ if (!fs.existsSync(configPath)) {
1083
+ return true;
1084
+ }
1085
+ let config;
1086
+ try {
1087
+ config = fse.readJsonSync(configPath);
1088
+ }
1089
+ catch (error) {
1090
+ this.addError({
1091
+ type: 'entity',
1092
+ severity: 'error',
1093
+ file: configPath,
1094
+ message: `Cannot read .mj-sync.json: ${this.readErrorReason(error, configPath)}`,
1095
+ });
1096
+ return false;
1097
+ }
1098
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
1099
+ this.addError({
1100
+ type: 'entity',
1101
+ severity: 'error',
1102
+ file: configPath,
1103
+ message: '.mj-sync.json must contain a JSON object',
1104
+ });
1105
+ return false;
1106
+ }
1107
+ const pushWhen = 'pushWhen' in config ? config.pushWhen : undefined;
1108
+ if (pushWhen === undefined) {
1109
+ return true;
1110
+ }
1111
+ const problem = PushWhenError(pushWhen);
1112
+ if (problem) {
1113
+ this.addError({
1114
+ type: 'entity',
1115
+ severity: 'error',
1116
+ file: configPath,
1117
+ message: problem,
1118
+ suggestion: 'Use "pushWhen": "@env:VARIABLE"',
1119
+ });
1120
+ return true;
1121
+ }
1122
+ return !this.options.HonorPushWhen || EvaluatePushWhen(pushWhen).Push;
1123
+ }
1124
+ /** The reason from a read error, without the leading `<path>: ` that fs-extra adds. */
1125
+ readErrorReason(error, filePath) {
1126
+ const message = error instanceof Error ? error.message : String(error);
1127
+ const prefix = `${filePath}: `;
1128
+ return message.startsWith(prefix) ? message.slice(prefix.length) : message;
1129
+ }
955
1130
  /**
956
1131
  * Apply include/exclude filters to directory list
957
1132
  */
@@ -971,21 +1146,10 @@ export class ValidationService {
971
1146
  }
972
1147
  return filteredDirs;
973
1148
  }
974
- /**
975
- * Get files matching pattern
976
- */
1149
+ /** The record files of an entity folder, found the way push finds them, as sorted absolute paths. */
977
1150
  async getMatchingFiles(dir, pattern) {
978
- const files = fs.readdirSync(dir).filter((f) => fs.statSync(path.join(dir, f)).isFile());
979
- // Strip leading **/ from glob patterns (we only match in current directory)
980
- const normalizedPattern = pattern.replace(/^\*\*\//, '');
981
- // Simple glob pattern matching
982
- if (normalizedPattern === '*.json') {
983
- return files.filter((f) => f.endsWith('.json') && !f.startsWith('.mj-'));
984
- }
985
- else if (normalizedPattern === '.*.json') {
986
- return files.filter((f) => f.startsWith('.') && f.endsWith('.json') && !f.startsWith('.mj-'));
987
- }
988
- return files;
1151
+ const files = await fastGlob(pattern || '*.json', { ...RECORD_FILE_GLOB_OPTIONS, cwd: dir });
1152
+ return files.sort();
989
1153
  }
990
1154
  /**
991
1155
  * Add an error
@@ -1070,6 +1234,7 @@ export class ValidationService {
1070
1234
  this.entityDependencies.clear();
1071
1235
  this.processedEntities.clear();
1072
1236
  this.claimedPrimaryKeys.clear();
1237
+ this.recordStartLines = new WeakMap();
1073
1238
  this.userRoleCache.clear();
1074
1239
  }
1075
1240
  /**
@@ -1302,6 +1467,63 @@ export class ValidationService {
1302
1467
  });
1303
1468
  }
1304
1469
  }
1470
+ /** Checks an `@env:` reference inside a JSON file: its syntax, and whether a required variable is set. */
1471
+ validateNestedEnvReference(value, sourceFile, entityName, key) {
1472
+ const reference = ParseEnvReference(value);
1473
+ if (!reference) {
1474
+ this.addError({
1475
+ type: 'reference',
1476
+ severity: 'error',
1477
+ entity: entityName,
1478
+ field: key,
1479
+ file: sourceFile,
1480
+ message: `Invalid reference format: "${value}"`,
1481
+ });
1482
+ return;
1483
+ }
1484
+ if (!reference.Optional && !process.env[reference.VariableName]) {
1485
+ this.addWarning({
1486
+ type: 'validation',
1487
+ severity: 'warning',
1488
+ entity: entityName,
1489
+ field: key,
1490
+ file: sourceFile,
1491
+ message: `Environment variable "${reference.VariableName}" is not currently set`,
1492
+ suggestion: `Ensure this variable is set before running push operations`,
1493
+ });
1494
+ }
1495
+ }
1496
+ /** Reports a primary key `@env:` reference that is malformed or uses `?optional`; push needs the value to find the record. */
1497
+ validatePrimaryKeyEnvReferences(primaryKey, entityName, filePath) {
1498
+ for (const [field, value] of Object.entries(primaryKey ?? {})) {
1499
+ if (typeof value !== 'string' || !value.startsWith(METADATA_KEYWORDS.ENV)) {
1500
+ continue;
1501
+ }
1502
+ const reference = ParseEnvReference(value);
1503
+ if (!reference) {
1504
+ this.addError({
1505
+ type: 'reference',
1506
+ severity: 'error',
1507
+ entity: entityName,
1508
+ field,
1509
+ file: filePath,
1510
+ message: `Invalid reference format: "${value}"`,
1511
+ suggestion: 'Use @env:VARIABLE',
1512
+ });
1513
+ }
1514
+ else if (reference.Optional) {
1515
+ this.addError({
1516
+ type: 'reference',
1517
+ severity: 'error',
1518
+ entity: entityName,
1519
+ field,
1520
+ file: filePath,
1521
+ message: `Primary key field "${field}" cannot use ?optional: ${value}`,
1522
+ suggestion: 'Remove ?optional from the primary key reference',
1523
+ });
1524
+ }
1525
+ }
1526
+ }
1305
1527
  /**
1306
1528
  * Recursively validates all @ references in a JSON structure
1307
1529
  */
@@ -1321,7 +1543,7 @@ export class ValidationService {
1321
1543
  if (value.startsWith(METADATA_KEYWORDS.FILE)) {
1322
1544
  const filePath = ExtractKeywordValue(value);
1323
1545
  // Recursively validate the file reference (with circular detection)
1324
- await this.validateFileReference(filePath, sourceFile, entityName, key, visitedFiles);
1546
+ await this.validateFileReference(filePath, sourceFile, path.dirname(sourceFile), entityName, key, visitedFiles);
1325
1547
  }
1326
1548
  else if (value.startsWith(METADATA_KEYWORDS.LOOKUP)) {
1327
1549
  // Parse and validate lookup reference
@@ -1331,8 +1553,7 @@ export class ValidationService {
1331
1553
  }
1332
1554
  }
1333
1555
  else if (value.startsWith(METADATA_KEYWORDS.TEMPLATE)) {
1334
- const templatePath = ExtractKeywordValue(value);
1335
- await this.validateTemplateReference(templatePath, sourceFile, entityName, key);
1556
+ this.addTemplateError(value, entityName, key, sourceFile);
1336
1557
  }
1337
1558
  else if (value.startsWith(METADATA_KEYWORDS.PARENT)) {
1338
1559
  const parsed = this.parseReference(value);
@@ -1353,18 +1574,7 @@ export class ValidationService {
1353
1574
  }
1354
1575
  }
1355
1576
  else if (value.startsWith(METADATA_KEYWORDS.ENV)) {
1356
- const envVar = ExtractKeywordValue(value);
1357
- if (!process.env[envVar]) {
1358
- this.addWarning({
1359
- type: 'validation',
1360
- severity: 'warning',
1361
- entity: entityName,
1362
- field: key,
1363
- file: sourceFile,
1364
- message: `Environment variable "${envVar}" is not currently set`,
1365
- suggestion: `Ensure this variable is set before running push operations`,
1366
- });
1367
- }
1577
+ this.validateNestedEnvReference(value, sourceFile, entityName, key);
1368
1578
  }
1369
1579
  }
1370
1580
  else if (value && typeof value === 'object') {
@@ -1516,7 +1726,7 @@ export class ValidationService {
1516
1726
  });
1517
1727
  return;
1518
1728
  }
1519
- await this.validateExtensionFields(extObj.fields, parentEntityInfo, childEntityInfo, filePath, config, depth);
1729
+ await this.validateExtensionFields(extObj.fields, extObj, parentEntityInfo, childEntityInfo, filePath, config, depth);
1520
1730
  return;
1521
1731
  }
1522
1732
  // Case 2: Map form where keys are subtype entity names (e.g. { "Event Order Lines": { fields: { ... } } })
@@ -1558,14 +1768,14 @@ export class ValidationService {
1558
1768
  }
1559
1769
  const subRecord = subVal;
1560
1770
  if ('fields' in subRecord && typeof subRecord.fields === 'object' && subRecord.fields !== null) {
1561
- await this.validateExtensionFields(subRecord.fields, parentEntityInfo, childEntityInfo, filePath, config, depth);
1771
+ await this.validateExtensionFields(subRecord.fields, subRecord, parentEntityInfo, childEntityInfo, filePath, config, depth);
1562
1772
  }
1563
1773
  }
1564
1774
  }
1565
1775
  /**
1566
1776
  * Validates leaf fields within an extension block (§4.1, §4.5, §9)
1567
1777
  */
1568
- async validateExtensionFields(fields, parentEntityInfo, childEntityInfo, filePath, config, depth) {
1778
+ async validateExtensionFields(fields, extensionRecord, parentEntityInfo, childEntityInfo, filePath, config, depth) {
1569
1779
  for (const fieldName of Object.keys(fields)) {
1570
1780
  const isParentField = parentEntityInfo.Fields.some((f) => f.Name.toLowerCase() === fieldName.toLowerCase());
1571
1781
  if (isParentField) {
@@ -1580,6 +1790,7 @@ export class ValidationService {
1580
1790
  });
1581
1791
  }
1582
1792
  }
1793
+ this.reportTemplateValues(fields, extensionRecord, childEntityInfo.Name, filePath);
1583
1794
  await this.validateFields(fields, childEntityInfo, filePath, { entity: parentEntityInfo.Name, field: 'extension' }, false, { entity: parentEntityInfo.Name });
1584
1795
  }
1585
1796
  /**