@memberjunction/metadata-sync 6.2.0-edge.4 → 6.2.0-edge.6
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 +120 -52
- package/dist/config.d.ts +18 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/RecordProcessor.d.ts +98 -0
- package/dist/lib/RecordProcessor.d.ts.map +1 -1
- package/dist/lib/RecordProcessor.js +424 -22
- package/dist/lib/RecordProcessor.js.map +1 -1
- package/dist/lib/RelatedEntityHandler.d.ts +4 -1
- package/dist/lib/RelatedEntityHandler.d.ts.map +1 -1
- package/dist/lib/RelatedEntityHandler.js +49 -11
- package/dist/lib/RelatedEntityHandler.js.map +1 -1
- package/dist/lib/created-record-key.d.ts +34 -0
- package/dist/lib/created-record-key.d.ts.map +1 -0
- package/dist/lib/created-record-key.js +46 -0
- package/dist/lib/created-record-key.js.map +1 -0
- package/dist/lib/deletion-auditor.d.ts.map +1 -1
- package/dist/lib/deletion-auditor.js +34 -3
- package/dist/lib/deletion-auditor.js.map +1 -1
- package/dist/lib/entity-field-names.d.ts +11 -0
- package/dist/lib/entity-field-names.d.ts.map +1 -0
- package/dist/lib/entity-field-names.js +15 -0
- package/dist/lib/entity-field-names.js.map +1 -0
- package/dist/lib/env-derived-fields.d.ts +23 -0
- package/dist/lib/env-derived-fields.d.ts.map +1 -0
- package/dist/lib/env-derived-fields.js +53 -0
- package/dist/lib/env-derived-fields.js.map +1 -0
- package/dist/lib/env-display.d.ts +54 -0
- package/dist/lib/env-display.d.ts.map +1 -0
- package/dist/lib/env-display.js +156 -0
- package/dist/lib/env-display.js.map +1 -0
- package/dist/lib/env-reference.d.ts +104 -0
- package/dist/lib/env-reference.d.ts.map +1 -0
- package/dist/lib/env-reference.js +241 -0
- package/dist/lib/env-reference.js.map +1 -0
- package/dist/lib/env-sql-redactor.d.ts +74 -0
- package/dist/lib/env-sql-redactor.d.ts.map +1 -0
- package/dist/lib/env-sql-redactor.js +232 -0
- package/dist/lib/env-sql-redactor.js.map +1 -0
- package/dist/lib/existing-record-index.d.ts +70 -0
- package/dist/lib/existing-record-index.d.ts.map +1 -0
- package/dist/lib/existing-record-index.js +158 -0
- package/dist/lib/existing-record-index.js.map +1 -0
- package/dist/lib/ignore-directories.d.ts +10 -0
- package/dist/lib/ignore-directories.d.ts.map +1 -0
- package/dist/lib/ignore-directories.js +12 -0
- package/dist/lib/ignore-directories.js.map +1 -0
- package/dist/lib/json-record-lines.d.ts +17 -0
- package/dist/lib/json-record-lines.d.ts.map +1 -0
- package/dist/lib/json-record-lines.js +170 -0
- package/dist/lib/json-record-lines.js.map +1 -0
- package/dist/lib/json-subproperty-externalization.d.ts.map +1 -1
- package/dist/lib/json-subproperty-externalization.js +3 -1
- package/dist/lib/json-subproperty-externalization.js.map +1 -1
- package/dist/lib/lookup-reference.d.ts +38 -0
- package/dist/lib/lookup-reference.d.ts.map +1 -0
- package/dist/lib/lookup-reference.js +62 -0
- package/dist/lib/lookup-reference.js.map +1 -0
- package/dist/lib/lookup-sql.d.ts +108 -0
- package/dist/lib/lookup-sql.d.ts.map +1 -0
- package/dist/lib/lookup-sql.js +231 -0
- package/dist/lib/lookup-sql.js.map +1 -0
- package/dist/lib/natural-key-preflight.d.ts +86 -0
- package/dist/lib/natural-key-preflight.d.ts.map +1 -0
- package/dist/lib/natural-key-preflight.js +173 -0
- package/dist/lib/natural-key-preflight.js.map +1 -0
- package/dist/lib/provider-utils.d.ts.map +1 -1
- package/dist/lib/provider-utils.js +2 -4
- package/dist/lib/provider-utils.js.map +1 -1
- package/dist/lib/pull-option-overrides.d.ts +24 -0
- package/dist/lib/pull-option-overrides.d.ts.map +1 -0
- package/dist/lib/pull-option-overrides.js +39 -0
- package/dist/lib/pull-option-overrides.js.map +1 -0
- package/dist/lib/push-directories.d.ts +42 -0
- package/dist/lib/push-directories.d.ts.map +1 -0
- package/dist/lib/push-directories.js +71 -0
- package/dist/lib/push-directories.js.map +1 -0
- package/dist/lib/push-when.d.ts +38 -0
- package/dist/lib/push-when.d.ts.map +1 -0
- package/dist/lib/push-when.js +84 -0
- package/dist/lib/push-when.js.map +1 -0
- package/dist/lib/record-dependency-analyzer.d.ts +11 -2
- package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
- package/dist/lib/record-dependency-analyzer.js +48 -71
- package/dist/lib/record-dependency-analyzer.js.map +1 -1
- package/dist/lib/record-file-glob.d.ts +11 -0
- package/dist/lib/record-file-glob.d.ts.map +1 -0
- package/dist/lib/record-file-glob.js +10 -0
- package/dist/lib/record-file-glob.js.map +1 -0
- package/dist/lib/reference-parser.d.ts +3 -2
- package/dist/lib/reference-parser.d.ts.map +1 -1
- package/dist/lib/reference-parser.js +27 -27
- package/dist/lib/reference-parser.js.map +1 -1
- package/dist/lib/schema-placeholders.d.ts +29 -0
- package/dist/lib/schema-placeholders.d.ts.map +1 -0
- package/dist/lib/schema-placeholders.js +72 -0
- package/dist/lib/schema-placeholders.js.map +1 -0
- package/dist/lib/sql-logger.d.ts +9 -1
- package/dist/lib/sql-logger.d.ts.map +1 -1
- package/dist/lib/sql-logger.js +12 -7
- package/dist/lib/sql-logger.js.map +1 -1
- package/dist/lib/sql-set-line.d.ts +26 -0
- package/dist/lib/sql-set-line.d.ts.map +1 -0
- package/dist/lib/sql-set-line.js +84 -0
- package/dist/lib/sql-set-line.js.map +1 -0
- package/dist/lib/sync-engine.d.ts +58 -4
- package/dist/lib/sync-engine.d.ts.map +1 -1
- package/dist/lib/sync-engine.js +243 -59
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/lib/sync-state-manager.d.ts +7 -3
- package/dist/lib/sync-state-manager.d.ts.map +1 -1
- package/dist/lib/sync-state-manager.js +19 -4
- package/dist/lib/sync-state-manager.js.map +1 -1
- package/dist/lib/watch-file-filter.d.ts +31 -0
- package/dist/lib/watch-file-filter.d.ts.map +1 -0
- package/dist/lib/watch-file-filter.js +64 -0
- package/dist/lib/watch-file-filter.js.map +1 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +44 -14
- package/dist/plugins/index.js.map +1 -1
- package/dist/services/PullService.d.ts +41 -1
- package/dist/services/PullService.d.ts.map +1 -1
- package/dist/services/PullService.js +261 -101
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.d.ts +80 -0
- package/dist/services/PushService.d.ts.map +1 -1
- package/dist/services/PushService.js +531 -189
- package/dist/services/PushService.js.map +1 -1
- package/dist/services/ValidationService.d.ts +43 -8
- package/dist/services/ValidationService.d.ts.map +1 -1
- package/dist/services/ValidationService.js +338 -127
- package/dist/services/ValidationService.js.map +1 -1
- package/dist/services/WatchService.d.ts +28 -0
- package/dist/services/WatchService.d.ts.map +1 -1
- package/dist/services/WatchService.js +226 -74
- package/dist/services/WatchService.js.map +1 -1
- package/dist/types/validation.d.ts +6 -0
- package/dist/types/validation.d.ts.map +1 -1
- 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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
*/
|