@memberjunction/metadata-sync 6.2.0-edge.3 → 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 (130) hide show
  1. package/README.md +117 -53
  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/entity-subclass-guard.d.ts +8 -7
  29. package/dist/lib/entity-subclass-guard.d.ts.map +1 -1
  30. package/dist/lib/entity-subclass-guard.js +20 -8
  31. package/dist/lib/entity-subclass-guard.js.map +1 -1
  32. package/dist/lib/env-derived-fields.d.ts +23 -0
  33. package/dist/lib/env-derived-fields.d.ts.map +1 -0
  34. package/dist/lib/env-derived-fields.js +53 -0
  35. package/dist/lib/env-derived-fields.js.map +1 -0
  36. package/dist/lib/env-display.d.ts +54 -0
  37. package/dist/lib/env-display.d.ts.map +1 -0
  38. package/dist/lib/env-display.js +156 -0
  39. package/dist/lib/env-display.js.map +1 -0
  40. package/dist/lib/env-reference.d.ts +104 -0
  41. package/dist/lib/env-reference.d.ts.map +1 -0
  42. package/dist/lib/env-reference.js +241 -0
  43. package/dist/lib/env-reference.js.map +1 -0
  44. package/dist/lib/env-sql-redactor.d.ts +74 -0
  45. package/dist/lib/env-sql-redactor.d.ts.map +1 -0
  46. package/dist/lib/env-sql-redactor.js +297 -0
  47. package/dist/lib/env-sql-redactor.js.map +1 -0
  48. package/dist/lib/existing-record-index.d.ts +70 -0
  49. package/dist/lib/existing-record-index.d.ts.map +1 -0
  50. package/dist/lib/existing-record-index.js +158 -0
  51. package/dist/lib/existing-record-index.js.map +1 -0
  52. package/dist/lib/ignore-directories.d.ts +10 -0
  53. package/dist/lib/ignore-directories.d.ts.map +1 -0
  54. package/dist/lib/ignore-directories.js +12 -0
  55. package/dist/lib/ignore-directories.js.map +1 -0
  56. package/dist/lib/json-record-lines.d.ts +17 -0
  57. package/dist/lib/json-record-lines.d.ts.map +1 -0
  58. package/dist/lib/json-record-lines.js +170 -0
  59. package/dist/lib/json-record-lines.js.map +1 -0
  60. package/dist/lib/json-subproperty-externalization.d.ts.map +1 -1
  61. package/dist/lib/json-subproperty-externalization.js +3 -1
  62. package/dist/lib/json-subproperty-externalization.js.map +1 -1
  63. package/dist/lib/lookup-reference.d.ts +38 -0
  64. package/dist/lib/lookup-reference.d.ts.map +1 -0
  65. package/dist/lib/lookup-reference.js +62 -0
  66. package/dist/lib/lookup-reference.js.map +1 -0
  67. package/dist/lib/provider-utils.d.ts.map +1 -1
  68. package/dist/lib/provider-utils.js +2 -4
  69. package/dist/lib/provider-utils.js.map +1 -1
  70. package/dist/lib/pull-option-overrides.d.ts +24 -0
  71. package/dist/lib/pull-option-overrides.d.ts.map +1 -0
  72. package/dist/lib/pull-option-overrides.js +39 -0
  73. package/dist/lib/pull-option-overrides.js.map +1 -0
  74. package/dist/lib/push-directories.d.ts +42 -0
  75. package/dist/lib/push-directories.d.ts.map +1 -0
  76. package/dist/lib/push-directories.js +71 -0
  77. package/dist/lib/push-directories.js.map +1 -0
  78. package/dist/lib/push-when.d.ts +38 -0
  79. package/dist/lib/push-when.d.ts.map +1 -0
  80. package/dist/lib/push-when.js +84 -0
  81. package/dist/lib/push-when.js.map +1 -0
  82. package/dist/lib/record-dependency-analyzer.d.ts +2 -0
  83. package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
  84. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  85. package/dist/lib/record-file-glob.d.ts +11 -0
  86. package/dist/lib/record-file-glob.d.ts.map +1 -0
  87. package/dist/lib/record-file-glob.js +10 -0
  88. package/dist/lib/record-file-glob.js.map +1 -0
  89. package/dist/lib/reference-parser.d.ts +3 -2
  90. package/dist/lib/reference-parser.d.ts.map +1 -1
  91. package/dist/lib/reference-parser.js +27 -27
  92. package/dist/lib/reference-parser.js.map +1 -1
  93. package/dist/lib/sync-engine.d.ts +52 -4
  94. package/dist/lib/sync-engine.d.ts.map +1 -1
  95. package/dist/lib/sync-engine.js +233 -61
  96. package/dist/lib/sync-engine.js.map +1 -1
  97. package/dist/lib/sync-state-manager.d.ts +7 -3
  98. package/dist/lib/sync-state-manager.d.ts.map +1 -1
  99. package/dist/lib/sync-state-manager.js +19 -4
  100. package/dist/lib/sync-state-manager.js.map +1 -1
  101. package/dist/lib/watch-file-filter.d.ts +31 -0
  102. package/dist/lib/watch-file-filter.d.ts.map +1 -0
  103. package/dist/lib/watch-file-filter.js +64 -0
  104. package/dist/lib/watch-file-filter.js.map +1 -0
  105. package/dist/plugins/index.d.ts.map +1 -1
  106. package/dist/plugins/index.js +55 -23
  107. package/dist/plugins/index.js.map +1 -1
  108. package/dist/services/FormattingService.d.ts +14 -7
  109. package/dist/services/FormattingService.d.ts.map +1 -1
  110. package/dist/services/FormattingService.js +22 -9
  111. package/dist/services/FormattingService.js.map +1 -1
  112. package/dist/services/PullService.d.ts +53 -1
  113. package/dist/services/PullService.d.ts.map +1 -1
  114. package/dist/services/PullService.js +323 -120
  115. package/dist/services/PullService.js.map +1 -1
  116. package/dist/services/PushService.d.ts +59 -0
  117. package/dist/services/PushService.d.ts.map +1 -1
  118. package/dist/services/PushService.js +448 -173
  119. package/dist/services/PushService.js.map +1 -1
  120. package/dist/services/ValidationService.d.ts +43 -8
  121. package/dist/services/ValidationService.d.ts.map +1 -1
  122. package/dist/services/ValidationService.js +338 -127
  123. package/dist/services/ValidationService.js.map +1 -1
  124. package/dist/services/WatchService.d.ts +26 -0
  125. package/dist/services/WatchService.d.ts.map +1 -1
  126. package/dist/services/WatchService.js +210 -75
  127. package/dist/services/WatchService.js.map +1 -1
  128. package/dist/types/validation.d.ts +6 -0
  129. package/dist/types/validation.d.ts.map +1 -1
  130. package/package.json +17 -16
@@ -1,4 +1,6 @@
1
1
  import { CompositeKey, Metadata, RunView } from '@memberjunction/core';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
2
4
  import { ordinalCompare, UUIDsEqual } from '@memberjunction/global';
3
5
  import { SyncEngine } from '../lib/sync-engine.js';
4
6
  import { JsonWriteHelper } from './json-write-helper.js';
@@ -6,7 +8,11 @@ import { EntityPropertyExtractor } from './EntityPropertyExtractor.js';
6
8
  import { FieldExternalizer } from './FieldExternalizer.js';
7
9
  import { RelatedEntityHandler } from './RelatedEntityHandler.js';
8
10
  import { FindSubPropertyExternalizations, ExternalizeSubProperties, FieldExternalizerAdapter, } from './json-subproperty-externalization.js';
9
- import { METADATA_KEYWORDS, createKeywordReference } from '../constants/metadata-keywords.js';
11
+ import { METADATA_KEYWORDS, ExtractKeywordValue, createKeywordReference, IsMetadataKeyword } from '../constants/metadata-keywords.js';
12
+ import { HasNestedEnvReference, IsJsonContainer, KeepEnvReference, KeepNestedEnvReferences, ParseEnvReference, } from './env-reference.js';
13
+ import { EnvSourcedValuesOf, FindEnvSourcedValue } from './env-derived-fields.js';
14
+ import { ExistingRecordIndex, KeepPrimaryKeyEnvReferences, UnsetPrimaryKeyVariableError } from './existing-record-index.js';
15
+ import { ParseLookupCreateFields, ParseLookupPairs, SplitLookupReference } from './lookup-reference.js';
10
16
  /**
11
17
  * Handles the core processing of individual record data into the sync format
12
18
  */
@@ -14,6 +20,10 @@ export class RecordProcessor {
14
20
  constructor(syncEngine, contextUser) {
15
21
  this.syncEngine = syncEngine;
16
22
  this.contextUser = contextUser;
23
+ /** Receives pull warnings. When not set, warnings go to the console. */
24
+ this.WarningSink = null;
25
+ /** For each record this processor returned, the read-only fields it left out because they hold an env value. */
26
+ this.envOmittedFields = new WeakMap();
17
27
  this.propertyExtractor = new EntityPropertyExtractor();
18
28
  this.fieldExternalizer = new FieldExternalizer();
19
29
  this.relatedEntityHandler = new RelatedEntityHandler(syncEngine, contextUser);
@@ -36,11 +46,29 @@ export class RecordProcessor {
36
46
  // Extract all properties from the entity
37
47
  const allProperties = this.propertyExtractor.extractAllProperties(record, fieldOverrides);
38
48
  // Process fields, collections, embeds, extension, and related entities (§6)
39
- const { fields, collections, embeds, extension, relatedEntities } = await this.processEntityData(allProperties, record, primaryKey, targetDir, entityConfig, existingRecordData, currentDepth, ancestryPath, verbose, batchedRelatedData);
49
+ const envOmitted = new Set();
50
+ const { fields, collections, embeds, extension, relatedEntities } = await this.processEntityData(allProperties, record, primaryKey, targetDir, entityConfig, existingRecordData, currentDepth, ancestryPath, verbose, batchedRelatedData, envOmitted);
40
51
  // Calculate checksum and sync metadata
41
52
  const syncData = await this.calculateSyncMetadata(fields, targetDir, entityConfig, existingRecordData, verbose, collections, embeds, extension);
42
53
  // Build the final record data with proper ordering
43
- return JsonWriteHelper.createOrderedRecordData(fields, relatedEntities, primaryKey, syncData, collections, embeds, extension);
54
+ const recordData = JsonWriteHelper.createOrderedRecordData(fields, relatedEntities, this.keptPrimaryKey(primaryKey, entityConfig, existingRecordData), syncData, collections, embeds, extension);
55
+ this.envOmittedFields.set(recordData, envOmitted);
56
+ return recordData;
57
+ }
58
+ /**
59
+ * The read-only fields left out of a record this processor returned, because their database
60
+ * value holds the value of an `@env:` reference in the record file. A merge removes them too.
61
+ */
62
+ EnvOmittedFieldsOf(recordData) {
63
+ return this.envOmittedFields.get(recordData) ?? new Set();
64
+ }
65
+ /** The key to write: the file's `@env:` key values that still resolve to the database key, else the database key. */
66
+ keptPrimaryKey(primaryKey, entityConfig, existingRecordData) {
67
+ const fileKey = existingRecordData?.primaryKey;
68
+ if (!fileKey || !Object.values(fileKey).some((value) => ParseEnvReference(value) !== null)) {
69
+ return primaryKey;
70
+ }
71
+ return KeepPrimaryKeyEnvReferences(fileKey, primaryKey, this.syncEngine.getEntityInfo(entityConfig.entity));
44
72
  }
45
73
  /** @deprecated Use {@link ProcessRecord}. */
46
74
  async processRecord(record, primaryKey, targetDir, entityConfig, verbose, isNewRecord = true, existingRecordData, currentDepth = 0, ancestryPath = new Set(), fieldOverrides, batchedRelatedData) {
@@ -49,19 +77,19 @@ export class RecordProcessor {
49
77
  /**
50
78
  * Processes entity data into fields and related entities
51
79
  */
52
- async processEntityData(allProperties, record, primaryKey, targetDir, entityConfig, existingRecordData, currentDepth, ancestryPath, verbose, batchedRelatedData) {
80
+ async processEntityData(allProperties, record, primaryKey, targetDir, entityConfig, existingRecordData, currentDepth, ancestryPath, verbose, batchedRelatedData, envOmitted = new Set()) {
53
81
  const fields = {};
54
82
  const collections = {};
55
83
  const embeds = {};
56
84
  const relatedEntities = {};
57
85
  // Process individual fields
58
- await this.processFields(allProperties, primaryKey, targetDir, entityConfig, existingRecordData, fields, verbose);
86
+ await this.processFields(allProperties, primaryKey, targetDir, entityConfig, existingRecordData, fields, verbose, envOmitted);
59
87
  // Process collections if declared (§6)
60
- await this.processCollections(record, targetDir, entityConfig, collections, currentDepth, ancestryPath, verbose);
88
+ await this.processCollections(record, targetDir, entityConfig, collections, currentDepth, ancestryPath, verbose, existingRecordData);
61
89
  // Process extension if present (§6)
62
- const extension = await this.processExtension(record, primaryKey, targetDir, entityConfig, verbose);
90
+ const extension = await this.processExtension(record, primaryKey, targetDir, entityConfig, verbose, existingRecordData);
63
91
  // Process embeds if present (§6)
64
- await this.processEmbeds(record, targetDir, entityConfig, embeds, currentDepth, ancestryPath, verbose);
92
+ await this.processEmbeds(record, targetDir, entityConfig, embeds, currentDepth, ancestryPath, verbose, existingRecordData);
65
93
  // Process related entities if configured
66
94
  await this.processRelatedEntities(record, entityConfig, existingRecordData, currentDepth, ancestryPath, relatedEntities, verbose, batchedRelatedData);
67
95
  return {
@@ -75,20 +103,352 @@ export class RecordProcessor {
75
103
  /**
76
104
  * Processes individual fields from the entity
77
105
  */
78
- async processFields(allProperties, primaryKey, targetDir, entityConfig, existingRecordData, fields, verbose) {
106
+ async processFields(allProperties, primaryKey, targetDir, entityConfig, existingRecordData, fields, verbose, envOmitted = new Set()) {
79
107
  const entityInfo = this.syncEngine.getEntityInfo(entityConfig.entity);
108
+ const envValues = EnvSourcedValuesOf(existingRecordData, entityInfo);
80
109
  for (const [fieldName, fieldValue] of Object.entries(allProperties)) {
81
- if (this.shouldSkipField(fieldName, fieldValue, primaryKey, entityConfig, entityInfo)) {
110
+ if (this.shouldSkipField(fieldName, primaryKey, entityConfig, entityInfo)) {
111
+ continue;
112
+ }
113
+ const keptValue = await this.keepEnvReferences(fieldName, fieldValue, allProperties, primaryKey, targetDir, entityConfig, existingRecordData, verbose);
114
+ if (keptValue !== null) {
115
+ fields[fieldName] = keptValue;
116
+ continue;
117
+ }
118
+ if (this.holdsEnvValueReadOnly(fieldName, fieldValue, envValues, entityInfo, primaryKey, entityConfig, existingRecordData, verbose)) {
119
+ envOmitted.add(fieldName);
120
+ continue;
121
+ }
122
+ if (this.shouldSkipNullField(fieldValue, entityConfig)) {
82
123
  continue;
83
124
  }
84
125
  let processedValue = await this.processFieldValue(fieldName, fieldValue, allProperties, targetDir, entityConfig, existingRecordData, verbose);
85
126
  fields[fieldName] = processedValue;
86
127
  }
87
128
  }
129
+ /**
130
+ * True when the field is read-only for push (not the key) and its database value holds the value of
131
+ * an `@env:` reference in the record file. Push ignores such a field, so pull leaves it out. With
132
+ * verbose, a note names the field and the variable, never the value.
133
+ */
134
+ holdsEnvValueReadOnly(fieldName, databaseValue, envValues, entityInfo, primaryKey, entityConfig, existingRecordData, verbose) {
135
+ const fieldInfo = entityInfo?.Fields.find((f) => f.Name === fieldName);
136
+ if (!fieldInfo?.ReadOnly || fieldInfo.IsPrimaryKey) {
137
+ return false;
138
+ }
139
+ const found = FindEnvSourcedValue(databaseValue, envValues);
140
+ if (!found) {
141
+ return false;
142
+ }
143
+ if (verbose) {
144
+ console.log(`${this.describeField(entityConfig, primaryKey, fieldName, existingRecordData)}: left out, it holds the value of @env:${found.VariableName} (read-only field)`);
145
+ }
146
+ return true;
147
+ }
148
+ /**
149
+ * The field value pull writes when the existing file value holds `@env:` references, so pull
150
+ * never writes a resolved environment value into a file.
151
+ * @returns The value to write, or null when the existing value holds no `@env:` reference
152
+ */
153
+ async keepEnvReferences(fieldName, databaseValue, allProperties, primaryKey, targetDir, entityConfig, existingRecordData, verbose) {
154
+ const flat = this.keepEnvReference(fieldName, databaseValue, primaryKey, entityConfig, existingRecordData, verbose);
155
+ if (flat !== null) {
156
+ return flat;
157
+ }
158
+ const nested = this.keepNestedEnvReferences(fieldName, databaseValue, primaryKey, entityConfig, existingRecordData, verbose);
159
+ if (nested !== null) {
160
+ if (nested.KeptFileValue) {
161
+ return nested.Value;
162
+ }
163
+ return await this.applyJsonSubPropertyExternalization(fieldName, nested.Value, allProperties, targetDir, entityConfig, existingRecordData, verbose);
164
+ }
165
+ const sideFile = await this.keepEnvReferencesInSideFile(fieldName, databaseValue, primaryKey, targetDir, entityConfig, existingRecordData, verbose);
166
+ if (sideFile !== null) {
167
+ return sideFile;
168
+ }
169
+ return await this.keepEnvLookupReference(fieldName, databaseValue, primaryKey, entityConfig, existingRecordData, verbose);
170
+ }
171
+ /**
172
+ * Keeps an existing `@lookup:` whose criteria or create fields use `@env:`. Loads the record the
173
+ * database foreign key points at and compares each criteria field the way push looks it up: the
174
+ * field name and the value without case, the value not trimmed, and `null` matching a null value.
175
+ * Keeps the lookup in every case: silently when it matches, with a verbose note when it cannot be
176
+ * checked, with a warning when the record is missing, cannot be read or does not match.
177
+ * @returns The lookup to keep, or null when the existing value is not such a lookup
178
+ */
179
+ async keepEnvLookupReference(fieldName, databaseValue, primaryKey, entityConfig, existingRecordData, verbose) {
180
+ const existingValue = existingRecordData?.fields?.[fieldName];
181
+ if (typeof existingValue !== 'string' || !existingValue.startsWith(METADATA_KEYWORDS.LOOKUP)) {
182
+ return null;
183
+ }
184
+ const lookup = this.parseEnvLookup(existingValue);
185
+ if (lookup === null) {
186
+ return null;
187
+ }
188
+ const where = this.describeField(entityConfig, primaryKey, fieldName, existingRecordData);
189
+ const keepUnchecked = (reason) => {
190
+ if (verbose) {
191
+ console.log(`${where}: ${reason} Kept ${existingValue} in the file.`);
192
+ }
193
+ return existingValue;
194
+ };
195
+ if (lookup === 'Unreadable') {
196
+ return keepUnchecked('the lookup criteria cannot be read, so the lookup was not checked.');
197
+ }
198
+ const expected = this.expectedLookupValues(lookup.Criteria);
199
+ if (typeof expected === 'string') {
200
+ return keepUnchecked(expected);
201
+ }
202
+ const problem = await this.lookupTargetProblem(lookup.EntityName, databaseValue, expected, [...lookup.Variables].join(', '));
203
+ if (problem !== null) {
204
+ this.warn(`${where}: ${problem} Kept ${existingValue} in the file.`);
205
+ }
206
+ return existingValue;
207
+ }
208
+ /**
209
+ * Reads a `@lookup:` value for the `@env:` keep.
210
+ * @returns The parsed lookup; `'Unreadable'` when it holds `@env:` but cannot be parsed; null
211
+ * when it holds no `@env:` reference
212
+ */
213
+ parseEnvLookup(lookupValue) {
214
+ const split = SplitLookupReference(lookupValue.substring(METADATA_KEYWORDS.LOOKUP.length));
215
+ const criteria = split ? ParseLookupPairs(split.CriteriaText) : null;
216
+ const createFields = split ? this.readLookupCreateFields(split.FlagsText) : null;
217
+ if (!split || !criteria || !createFields) {
218
+ return lookupValue.includes(METADATA_KEYWORDS.ENV) ? 'Unreadable' : null;
219
+ }
220
+ const variables = new Set();
221
+ for (const pair of [...criteria, ...createFields]) {
222
+ const reference = ParseEnvReference(pair.Value);
223
+ if (reference) {
224
+ variables.add(reference.VariableName);
225
+ }
226
+ }
227
+ return variables.size === 0 ? null : { EntityName: split.EntityName, Criteria: criteria, Variables: variables };
228
+ }
229
+ /** The lookup's create fields, or null when a value cannot be URI-decoded. */
230
+ readLookupCreateFields(flagsText) {
231
+ try {
232
+ return ParseLookupCreateFields(flagsText);
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ /**
239
+ * The value each criteria field must have, with `@env:` values read from the environment.
240
+ * @returns The values, or the reason the lookup cannot be checked
241
+ */
242
+ expectedLookupValues(criteria) {
243
+ const expected = [];
244
+ for (const pair of criteria) {
245
+ const reference = ParseEnvReference(pair.Value);
246
+ let value;
247
+ if (reference) {
248
+ const envValue = process.env[reference.VariableName];
249
+ if (envValue === undefined) {
250
+ return `${reference.VariableName} is not set, so the lookup was not checked.`;
251
+ }
252
+ value = envValue;
253
+ }
254
+ else if (IsMetadataKeyword(pair.Value)) {
255
+ return 'the lookup criteria use a reference pull cannot check.';
256
+ }
257
+ else {
258
+ value = pair.Value;
259
+ }
260
+ expected.push({ Name: pair.Name, Value: value.trim().toLowerCase() === 'null' ? null : value });
261
+ }
262
+ return expected;
263
+ }
264
+ /**
265
+ * Checks the record the foreign key points at against the expected criteria values.
266
+ * @returns The warning text without values, or null when the record matches
267
+ */
268
+ async lookupTargetProblem(entityName, databaseValue, expected, variables) {
269
+ if (databaseValue === null || databaseValue === undefined) {
270
+ return `the database value is null, so it does not match the lookup that uses ${variables}.`;
271
+ }
272
+ const target = await this.loadLookupTarget(entityName, String(databaseValue));
273
+ if (target.Status === 'NotFound') {
274
+ return `the record the database value points at was not found, so it does not match the lookup that uses ${variables}.`;
275
+ }
276
+ if (target.Status === 'Failed') {
277
+ return `the record the database value points at could not be read, so the lookup that uses ${variables} was not checked.`;
278
+ }
279
+ if (!this.lookupRowMatches(target.Row, expected)) {
280
+ return `the database value does not match the lookup that uses ${variables}.`;
281
+ }
282
+ return null;
283
+ }
284
+ /** Compares a row with the expected values: field names and values without case, values not trimmed. */
285
+ lookupRowMatches(row, expected) {
286
+ const valuesByName = new Map(Object.entries(row).map(([name, value]) => [name.toLowerCase(), value]));
287
+ return expected.every(({ Name, Value }) => {
288
+ const actual = valuesByName.get(Name.toLowerCase());
289
+ if (Value === null) {
290
+ return actual === null || actual === undefined;
291
+ }
292
+ return actual !== null && actual !== undefined && String(actual).toLowerCase() === Value.toLowerCase();
293
+ });
294
+ }
295
+ /** Reads the record a foreign key points at, as plain values. */
296
+ async loadLookupTarget(entityName, foreignKeyValue) {
297
+ try {
298
+ const entityInfo = this.syncEngine.getEntityInfo(entityName);
299
+ if (!entityInfo) {
300
+ return { Status: 'Failed' };
301
+ }
302
+ const result = await new RunView().RunView({
303
+ EntityName: entityName,
304
+ ExtraFilter: CompositeKey.FromURLSegment(entityInfo, foreignKeyValue).ToWhereClause(),
305
+ ResultType: 'simple',
306
+ }, this.contextUser);
307
+ if (!result.Success) {
308
+ return { Status: 'Failed' };
309
+ }
310
+ if (result.Results.length === 0) {
311
+ return { Status: 'NotFound' };
312
+ }
313
+ return { Status: 'Found', Row: result.Results[0] };
314
+ }
315
+ catch {
316
+ return { Status: 'Failed' };
317
+ }
318
+ }
319
+ /**
320
+ * Returns the field's existing `@env:` reference. Warns when the variable differs from the database value.
321
+ * @returns The reference to keep, or null when the existing value is not an `@env:` reference
322
+ */
323
+ keepEnvReference(fieldName, databaseValue, primaryKey, entityConfig, existingRecordData, verbose) {
324
+ const kept = KeepEnvReference(existingRecordData?.fields?.[fieldName], this.serializeDateValue(databaseValue));
325
+ if (!kept) {
326
+ return null;
327
+ }
328
+ this.reportKeptReference(this.describeField(entityConfig, primaryKey, fieldName, existingRecordData), kept, verbose);
329
+ return kept.Reference;
330
+ }
331
+ /**
332
+ * Keeps `@env:` references inside a JSON field. When the file value is kept whole, warns why
333
+ * (a verbose note only when the database value is null).
334
+ */
335
+ keepNestedEnvReferences(fieldName, databaseValue, primaryKey, entityConfig, existingRecordData, verbose) {
336
+ const result = KeepNestedEnvReferences(existingRecordData?.fields?.[fieldName], databaseValue);
337
+ if (!result) {
338
+ return null;
339
+ }
340
+ const where = this.describeField(entityConfig, primaryKey, fieldName, existingRecordData);
341
+ this.reportNestedKeep(where, result, databaseValue, verbose);
342
+ return { Value: result.Value, KeptFileValue: result.KeptFileValue };
343
+ }
344
+ /**
345
+ * Gives the warnings and verbose notes for a nested keep. Messages name paths and variables only.
346
+ * @param sideFile The side file the references are in, named in each message when given
347
+ */
348
+ reportNestedKeep(where, result, databaseValue, verbose, sideFile) {
349
+ const inFile = sideFile ? ` (side file ${sideFile})` : '';
350
+ if (result.KeptFileValue) {
351
+ if (result.LeftValueVariables.length > 0) {
352
+ this.warn(`${where}${inFile}: the database value holds the value of ${result.LeftValueVariables.join(', ')} at a path the file does not reference. Kept the file value, which holds @env: references.`);
353
+ }
354
+ else if (databaseValue === null || databaseValue === undefined) {
355
+ if (verbose) {
356
+ console.log(`${where}${inFile}: the database value is null. Kept the file value, which holds @env: references.`);
357
+ }
358
+ }
359
+ else {
360
+ this.warn(`${where}${inFile}: the database value is not JSON. Kept the file value, which holds @env: references.`);
361
+ }
362
+ return;
363
+ }
364
+ for (const item of result.Kept) {
365
+ const at = item.Path.startsWith('[') ? `${where}${item.Path}` : `${where}.${item.Path}`;
366
+ this.reportKeptReference(`${at}${inFile}`, item, verbose);
367
+ }
368
+ }
369
+ /**
370
+ * Keeps `@env:` references in the `.json` side file that the field names with `@file:`. Writes the
371
+ * database JSON with the references put back to the side file when it differs, and keeps the
372
+ * `@file:` reference in the record. A side file with `@include` directives or nested `@file:`
373
+ * references is not rewritten.
374
+ * @returns The `@file:` reference to keep, or null when the field is not such a side file
375
+ */
376
+ async keepEnvReferencesInSideFile(fieldName, databaseValue, primaryKey, targetDir, entityConfig, existingRecordData, verbose) {
377
+ const existingValue = existingRecordData?.fields?.[fieldName];
378
+ if (typeof existingValue !== 'string' || !existingValue.startsWith(METADATA_KEYWORDS.FILE)) {
379
+ return null;
380
+ }
381
+ const relativePath = ExtractKeywordValue(existingValue);
382
+ if (!relativePath || !relativePath.endsWith('.json')) {
383
+ return null;
384
+ }
385
+ const sideFilePath = path.resolve(targetDir, relativePath);
386
+ const sideFile = await this.readJsonSideFile(sideFilePath);
387
+ if (!sideFile || !HasNestedEnvReference(sideFile.Json)) {
388
+ return null;
389
+ }
390
+ const where = this.describeField(entityConfig, primaryKey, fieldName, existingRecordData);
391
+ const parsedText = JSON.stringify(sideFile.Json);
392
+ if (parsedText.includes('"@include') || parsedText.includes(`"${METADATA_KEYWORDS.FILE}`)) {
393
+ this.warn(`${where} (side file ${relativePath}): the side file holds @env: references and @include or @file: references. Pull did not rewrite it.`);
394
+ return existingValue;
395
+ }
396
+ const result = KeepNestedEnvReferences(sideFile.Json, databaseValue);
397
+ if (!result) {
398
+ return null;
399
+ }
400
+ this.reportNestedKeep(where, result, databaseValue, verbose, relativePath);
401
+ if (!result.KeptFileValue && JSON.stringify(result.Value) !== JSON.stringify(sideFile.Json)) {
402
+ // The side file is written here, outside the pull's FileWriteBatch and its backup.
403
+ await fs.writeJson(sideFilePath, result.Value, { spaces: 2 });
404
+ if (verbose) {
405
+ console.log(`${where} (side file ${relativePath}): updated the side file and kept its @env: references.`);
406
+ }
407
+ }
408
+ return existingValue;
409
+ }
410
+ /**
411
+ * Reads a JSON side file as push does: a leading byte order mark is ignored.
412
+ * @returns The parsed JSON, or null when the file is missing or does not parse
413
+ */
414
+ async readJsonSideFile(filePath) {
415
+ if (!(await fs.pathExists(filePath))) {
416
+ return null;
417
+ }
418
+ try {
419
+ const text = (await fs.readFile(filePath, 'utf8')).replace(/^\uFEFF/, '');
420
+ return { Json: JSON.parse(text) };
421
+ }
422
+ catch {
423
+ return null;
424
+ }
425
+ }
426
+ /** Warns on a drifted kept reference, and notes an unset variable when verbose. Never prints a value. */
427
+ reportKeptReference(where, kept, verbose) {
428
+ if (kept.Check === 'Drift') {
429
+ this.warn(`${where}: the database value does not match ${kept.VariableName}. Kept ${kept.Reference} in the file.`);
430
+ }
431
+ else if (kept.Check === 'Unset' && verbose) {
432
+ console.log(`${where}: ${kept.VariableName} is not set, so it was not compared. Kept ${kept.Reference} in the file.`);
433
+ }
434
+ }
435
+ /** Names a field in messages. An `@env:` key value is shown as the file has it, never resolved. */
436
+ describeField(entityConfig, primaryKey, fieldName, existingRecordData) {
437
+ const key = this.keptPrimaryKey(primaryKey, entityConfig, existingRecordData);
438
+ return `${entityConfig.entity} ${JSON.stringify(key)} field ${fieldName}`;
439
+ }
440
+ warn(message) {
441
+ if (this.WarningSink) {
442
+ this.WarningSink(message);
443
+ }
444
+ else {
445
+ console.warn(message);
446
+ }
447
+ }
88
448
  /**
89
449
  * Determines if a field should be skipped during processing
90
450
  */
91
- shouldSkipField(fieldName, fieldValue, primaryKey, entityConfig, entityInfo) {
451
+ shouldSkipField(fieldName, primaryKey, entityConfig, entityInfo) {
92
452
  // Skip primary key fields
93
453
  if (primaryKey[fieldName] !== undefined) {
94
454
  return true;
@@ -105,12 +465,14 @@ export class RecordProcessor {
105
465
  if (this.shouldSkipVirtualField(fieldName, entityConfig, entityInfo)) {
106
466
  return true;
107
467
  }
108
- // Skip null fields if configured
109
- if (entityConfig.pull?.ignoreNullFields && fieldValue === null) {
110
- return true;
111
- }
112
468
  return false;
113
469
  }
470
+ /**
471
+ * True when the field is null and the config sets `ignoreNullFields`
472
+ */
473
+ shouldSkipNullField(fieldValue, entityConfig) {
474
+ return !!entityConfig.pull?.ignoreNullFields && fieldValue === null;
475
+ }
114
476
  /**
115
477
  * Checks if a virtual field should be skipped
116
478
  */
@@ -296,6 +658,9 @@ export class RecordProcessor {
296
658
  }
297
659
  }
298
660
  catch (error) {
661
+ if (error instanceof UnsetPrimaryKeyVariableError) {
662
+ throw error;
663
+ }
299
664
  if (verbose) {
300
665
  console.warn(`Failed to load related entities for ${relationKey}: ${error}`);
301
666
  }
@@ -348,7 +713,7 @@ export class RecordProcessor {
348
713
  /**
349
714
  * Processes first-class collections on pull (§6)
350
715
  */
351
- async processCollections(record, targetDir, entityConfig, collections, currentDepth, ancestryPath, verbose) {
716
+ async processCollections(record, targetDir, entityConfig, collections, currentDepth, ancestryPath, verbose, existingRecordData) {
352
717
  if (!record.EntityInfo?.RelatedEntities)
353
718
  return;
354
719
  for (const rel of record.EntityInfo.RelatedEntities) {
@@ -404,12 +769,16 @@ export class RecordProcessor {
404
769
  }
405
770
  return 0;
406
771
  });
772
+ const existingChildren = existingRecordData?.collections?.[colName] ?? [];
773
+ const childIndex = ExistingRecordIndex.Build(existingChildren.map((existingChild) => ({ Record: existingChild })), childEntityInfo);
774
+ childIndex.ThrowIfUnsetKeyVariables(childEntityInfo.Name);
407
775
  for (const child of items) {
408
776
  const childPK = {};
409
777
  for (const pk of childEntityInfo.PrimaryKeys) {
410
778
  childPK[pk.Name] = child.Get(pk.Name);
411
779
  }
412
- const childData = await this.ProcessRecord(child, childPK, targetDir, childConfig, verbose, false, undefined, currentDepth + 1, new Set([...(ancestryPath ?? []), `${record.EntityInfo.Name}:${JSON.stringify(record.PrimaryKey)}`]));
780
+ const existingChild = childIndex.Find(childPK)?.Record;
781
+ const childData = await this.ProcessRecord(child, childPK, targetDir, childConfig, verbose, !existingChild, existingChild, currentDepth + 1, new Set([...(ancestryPath ?? []), `${record.EntityInfo.Name}:${JSON.stringify(record.PrimaryKey)}`]));
413
782
  collections[colName].push(childData);
414
783
  }
415
784
  }
@@ -418,7 +787,7 @@ export class RecordProcessor {
418
787
  /**
419
788
  * Processes first-class extension on pull (§6)
420
789
  */
421
- async processExtension(record, primaryKey, targetDir, entityConfig, verbose) {
790
+ async processExtension(record, primaryKey, targetDir, entityConfig, verbose, existingRecordData) {
422
791
  const child = record.ISAChild;
423
792
  if (!child)
424
793
  return undefined;
@@ -426,6 +795,11 @@ export class RecordProcessor {
426
795
  const parentFieldNames = new Set(record.EntityInfo.Fields.map((f) => f.Name.toLowerCase()));
427
796
  const childProperties = this.propertyExtractor.extractAllProperties(child);
428
797
  const leafFields = {};
798
+ const childConfig = { entity: child.EntityInfo.Name };
799
+ const existingExtension = {
800
+ fields: this.existingExtensionFields(existingRecordData, child.EntityInfo.Name) ?? {},
801
+ ...(existingRecordData?.primaryKey ? { primaryKey: existingRecordData.primaryKey } : {}),
802
+ };
429
803
  for (const [fName, fVal] of Object.entries(childProperties)) {
430
804
  if (parentFieldNames.has(fName.toLowerCase()))
431
805
  continue;
@@ -433,7 +807,8 @@ export class RecordProcessor {
433
807
  continue;
434
808
  if (primaryKey[fName] !== undefined)
435
809
  continue;
436
- leafFields[fName] = fVal;
810
+ const kept = await this.keepEnvReferences(fName, fVal, childProperties, primaryKey, targetDir, childConfig, existingExtension, verbose);
811
+ leafFields[fName] = kept ?? fVal;
437
812
  }
438
813
  // Determine if child type is ambiguous (more than one subtype exists in metadata)
439
814
  const provider = Metadata.Provider; // global-provider-ok: MetadataSync is a single-provider CLI process
@@ -449,7 +824,7 @@ export class RecordProcessor {
449
824
  /**
450
825
  * Processes first-class embeds on pull (§6)
451
826
  */
452
- async processEmbeds(record, targetDir, entityConfig, embeds, currentDepth, ancestryPath, verbose) {
827
+ async processEmbeds(record, targetDir, entityConfig, embeds, currentDepth, ancestryPath, verbose, existingRecordData) {
453
828
  for (const field of record.EntityInfo.Fields) {
454
829
  if (!field.RelatedEntity)
455
830
  continue;
@@ -463,18 +838,45 @@ export class RecordProcessor {
463
838
  for (const pk of embedded.EntityInfo.PrimaryKeys) {
464
839
  embedPK[pk.Name] = embedded.Get(pk.Name);
465
840
  }
841
+ const existingEmbed = existingRecordData?.embeds?.[field.Name];
842
+ const embedKey = this.keptEmbedPrimaryKey(embedPK, embedded.EntityInfo, existingEmbed);
843
+ const embedConfig = { entity: embedded.EntityInfo.Name };
466
844
  for (const [fName, fVal] of Object.entries(embedProperties)) {
467
845
  if (fName.startsWith('__mj_') || embedPK[fName] !== undefined)
468
846
  continue;
469
- embedFields[fName] = fVal;
847
+ const kept = await this.keepEnvReferences(fName, fVal, embedProperties, embedPK, targetDir, embedConfig, existingEmbed, verbose);
848
+ embedFields[fName] = kept ?? fVal;
470
849
  }
471
850
  embeds[field.Name] = {
472
851
  fields: embedFields,
473
- ...(Object.keys(embedPK).length > 0 ? { primaryKey: embedPK } : {}),
852
+ ...(Object.keys(embedPK).length > 0 ? { primaryKey: embedKey } : {}),
474
853
  };
475
854
  }
476
855
  }
477
856
  }
857
+ /**
858
+ * The key pull writes for an embed: the existing embed's `@env:` key values that still resolve to the database key.
859
+ * @throws UnsetPrimaryKeyVariableError when an existing embed key variable is not set
860
+ */
861
+ keptEmbedPrimaryKey(embedPK, embedEntityInfo, existingEmbed) {
862
+ if (!existingEmbed?.primaryKey) {
863
+ return embedPK;
864
+ }
865
+ ExistingRecordIndex.Build([{ Record: existingEmbed }], embedEntityInfo).ThrowIfUnsetKeyVariables(embedEntityInfo.Name);
866
+ return KeepPrimaryKeyEnvReferences(existingEmbed.primaryKey, embedPK, embedEntityInfo);
867
+ }
868
+ /** The fields of the existing extension, in its shorthand form or its map form keyed by entity name. */
869
+ existingExtensionFields(existingRecordData, childEntityName) {
870
+ const extension = existingRecordData?.extension;
871
+ if (!extension) {
872
+ return undefined;
873
+ }
874
+ const shorthandFields = extension.fields;
875
+ if (IsJsonContainer(shorthandFields) && !Array.isArray(shorthandFields)) {
876
+ return shorthandFields;
877
+ }
878
+ return extension[childEntityName]?.fields;
879
+ }
478
880
  /**
479
881
  * Checks if the record has externalized fields
480
882
  */