@memberjunction/metadata-sync 2.66.0 → 2.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/EntityPropertyExtractor.d.ts +60 -0
- package/dist/lib/EntityPropertyExtractor.js +166 -0
- package/dist/lib/EntityPropertyExtractor.js.map +1 -0
- package/dist/lib/FieldExternalizer.d.ts +62 -0
- package/dist/lib/FieldExternalizer.js +177 -0
- package/dist/lib/FieldExternalizer.js.map +1 -0
- package/dist/lib/RecordProcessor.d.ts +82 -0
- package/dist/lib/RecordProcessor.js +309 -0
- package/dist/lib/RecordProcessor.js.map +1 -0
- package/dist/lib/RelatedEntityHandler.d.ts +75 -0
- package/dist/lib/RelatedEntityHandler.js +273 -0
- package/dist/lib/RelatedEntityHandler.js.map +1 -0
- package/dist/lib/file-write-batch.d.ts +61 -0
- package/dist/lib/file-write-batch.js +180 -0
- package/dist/lib/file-write-batch.js.map +1 -0
- package/dist/lib/json-write-helper.d.ts +39 -0
- package/dist/lib/json-write-helper.js +105 -0
- package/dist/lib/json-write-helper.js.map +1 -0
- package/dist/services/FileResetService.js +2 -1
- package/dist/services/FileResetService.js.map +1 -1
- package/dist/services/PullService.d.ts +22 -2
- package/dist/services/PullService.js +268 -173
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.js +3 -2
- package/dist/services/PushService.js.map +1 -1
- package/dist/services/WatchService.js +3 -2
- package/dist/services/WatchService.js.map +1 -1
- package/package.json +7 -7
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { BaseEntity, UserInfo } from '@memberjunction/core';
|
|
2
|
+
import { SyncEngine, RecordData } from '../lib/sync-engine';
|
|
3
|
+
import { RelatedEntityConfig, EntityConfig } from '../config';
|
|
4
|
+
/**
|
|
5
|
+
* Handles loading and processing of related entities for records
|
|
6
|
+
*/
|
|
7
|
+
export declare class RelatedEntityHandler {
|
|
8
|
+
private syncEngine;
|
|
9
|
+
private contextUser;
|
|
10
|
+
constructor(syncEngine: SyncEngine, contextUser: UserInfo);
|
|
11
|
+
/**
|
|
12
|
+
* Load related entities for a record
|
|
13
|
+
*/
|
|
14
|
+
loadRelatedEntities(parentRecord: BaseEntity, relationConfig: RelatedEntityConfig, parentEntityConfig: EntityConfig, existingRelatedEntities: RecordData[], processRecordData: (record: BaseEntity, primaryKey: Record<string, any>, targetDir: string, entityConfig: EntityConfig, verbose?: boolean, isNewRecord?: boolean, existingRecordData?: RecordData, currentDepth?: number, ancestryPath?: Set<string>, fieldOverrides?: Record<string, any>) => Promise<RecordData>, currentDepth: number, ancestryPath: Set<string>, verbose?: boolean): Promise<RecordData[]>;
|
|
15
|
+
/**
|
|
16
|
+
* Queries the database for related entities
|
|
17
|
+
*/
|
|
18
|
+
private queryRelatedEntities;
|
|
19
|
+
/**
|
|
20
|
+
* Builds the filter for querying related entities
|
|
21
|
+
*/
|
|
22
|
+
private buildRelatedEntityFilter;
|
|
23
|
+
/**
|
|
24
|
+
* Creates entity config for related entity processing
|
|
25
|
+
*/
|
|
26
|
+
private createRelatedEntityConfig;
|
|
27
|
+
/**
|
|
28
|
+
* Processes all related records (both existing and new)
|
|
29
|
+
*/
|
|
30
|
+
private processRelatedRecords;
|
|
31
|
+
/**
|
|
32
|
+
* Builds a map of database records by primary key for efficient lookup
|
|
33
|
+
*/
|
|
34
|
+
private buildDatabaseRecordMap;
|
|
35
|
+
/**
|
|
36
|
+
* Processes existing related entities
|
|
37
|
+
*/
|
|
38
|
+
private processExistingRelatedEntities;
|
|
39
|
+
/**
|
|
40
|
+
* Processes a single existing related entity
|
|
41
|
+
*/
|
|
42
|
+
private processExistingRelatedEntity;
|
|
43
|
+
/**
|
|
44
|
+
* Processes new related entities
|
|
45
|
+
*/
|
|
46
|
+
private processNewRelatedEntities;
|
|
47
|
+
/**
|
|
48
|
+
* Processes a single new related entity
|
|
49
|
+
*/
|
|
50
|
+
private processNewRelatedEntity;
|
|
51
|
+
/**
|
|
52
|
+
* Creates field overrides for @parent:ID replacement
|
|
53
|
+
*/
|
|
54
|
+
private createFieldOverrides;
|
|
55
|
+
/**
|
|
56
|
+
* Builds primary key for a record
|
|
57
|
+
*/
|
|
58
|
+
private buildPrimaryKeyForRecord;
|
|
59
|
+
/**
|
|
60
|
+
* Get the primary key value from a record
|
|
61
|
+
*/
|
|
62
|
+
private getRecordPrimaryKey;
|
|
63
|
+
/**
|
|
64
|
+
* Get a field value from a record, handling both entity objects and plain objects
|
|
65
|
+
*/
|
|
66
|
+
private getFieldValue;
|
|
67
|
+
/**
|
|
68
|
+
* Log warning message if verbose mode is enabled
|
|
69
|
+
*/
|
|
70
|
+
private logWarning;
|
|
71
|
+
/**
|
|
72
|
+
* Log error message if verbose mode is enabled
|
|
73
|
+
*/
|
|
74
|
+
private logError;
|
|
75
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RelatedEntityHandler = void 0;
|
|
4
|
+
const core_1 = require("@memberjunction/core");
|
|
5
|
+
/**
|
|
6
|
+
* Handles loading and processing of related entities for records
|
|
7
|
+
*/
|
|
8
|
+
class RelatedEntityHandler {
|
|
9
|
+
syncEngine;
|
|
10
|
+
contextUser;
|
|
11
|
+
constructor(syncEngine, contextUser) {
|
|
12
|
+
this.syncEngine = syncEngine;
|
|
13
|
+
this.contextUser = contextUser;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Load related entities for a record
|
|
17
|
+
*/
|
|
18
|
+
async loadRelatedEntities(parentRecord, relationConfig, parentEntityConfig, existingRelatedEntities, processRecordData, currentDepth, ancestryPath, verbose) {
|
|
19
|
+
try {
|
|
20
|
+
const parentPrimaryKey = this.getRecordPrimaryKey(parentRecord);
|
|
21
|
+
if (!parentPrimaryKey) {
|
|
22
|
+
this.logWarning('Unable to determine primary key for parent record', verbose);
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const relatedRecords = await this.queryRelatedEntities(parentPrimaryKey, relationConfig, verbose);
|
|
26
|
+
if (!relatedRecords) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const relatedEntityConfig = this.createRelatedEntityConfig(relationConfig, parentEntityConfig);
|
|
30
|
+
return await this.processRelatedRecords(relatedRecords, relationConfig, relatedEntityConfig, existingRelatedEntities, processRecordData, currentDepth, ancestryPath, verbose);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
this.logError(`Error loading related entities for ${relationConfig.entity}`, error, verbose);
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Queries the database for related entities
|
|
39
|
+
*/
|
|
40
|
+
async queryRelatedEntities(parentPrimaryKey, relationConfig, verbose) {
|
|
41
|
+
const filter = this.buildRelatedEntityFilter(parentPrimaryKey, relationConfig);
|
|
42
|
+
if (verbose) {
|
|
43
|
+
console.log(`Loading related entities: ${relationConfig.entity} with filter: ${filter}`);
|
|
44
|
+
}
|
|
45
|
+
const rv = new core_1.RunView();
|
|
46
|
+
const result = await rv.RunView({
|
|
47
|
+
EntityName: relationConfig.entity,
|
|
48
|
+
ExtraFilter: filter,
|
|
49
|
+
ResultType: 'entity_object'
|
|
50
|
+
}, this.contextUser);
|
|
51
|
+
if (!result.Success) {
|
|
52
|
+
this.logWarning(`Failed to load related entities ${relationConfig.entity}: ${result.ErrorMessage}`, verbose);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (verbose) {
|
|
56
|
+
console.log(`Found ${result.Results.length} related records for ${relationConfig.entity}`);
|
|
57
|
+
}
|
|
58
|
+
return result.Results;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Builds the filter for querying related entities
|
|
62
|
+
*/
|
|
63
|
+
buildRelatedEntityFilter(parentPrimaryKey, relationConfig) {
|
|
64
|
+
let filter = `${relationConfig.foreignKey} = '${parentPrimaryKey}'`;
|
|
65
|
+
if (relationConfig.filter) {
|
|
66
|
+
filter += ` AND (${relationConfig.filter})`;
|
|
67
|
+
}
|
|
68
|
+
return filter;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Creates entity config for related entity processing
|
|
72
|
+
*/
|
|
73
|
+
createRelatedEntityConfig(relationConfig, parentEntityConfig) {
|
|
74
|
+
return {
|
|
75
|
+
entity: relationConfig.entity,
|
|
76
|
+
pull: {
|
|
77
|
+
excludeFields: relationConfig.excludeFields || [],
|
|
78
|
+
lookupFields: relationConfig.lookupFields || {},
|
|
79
|
+
externalizeFields: relationConfig.externalizeFields || [],
|
|
80
|
+
relatedEntities: relationConfig.relatedEntities || {},
|
|
81
|
+
ignoreVirtualFields: parentEntityConfig.pull?.ignoreVirtualFields || false,
|
|
82
|
+
ignoreNullFields: parentEntityConfig.pull?.ignoreNullFields || false
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Processes all related records (both existing and new)
|
|
88
|
+
*/
|
|
89
|
+
async processRelatedRecords(dbRecords, relationConfig, relatedEntityConfig, existingRelatedEntities, processRecordData, currentDepth, ancestryPath, verbose) {
|
|
90
|
+
const dbRecordMap = this.buildDatabaseRecordMap(dbRecords);
|
|
91
|
+
const relatedRecords = [];
|
|
92
|
+
const processedIds = new Set();
|
|
93
|
+
// Process existing related entities first (preserving order)
|
|
94
|
+
await this.processExistingRelatedEntities(existingRelatedEntities, dbRecordMap, relationConfig, relatedEntityConfig, processRecordData, currentDepth, ancestryPath, relatedRecords, processedIds, verbose);
|
|
95
|
+
// Process new related entities (append to end)
|
|
96
|
+
await this.processNewRelatedEntities(dbRecords, relationConfig, relatedEntityConfig, processRecordData, currentDepth, ancestryPath, relatedRecords, processedIds, verbose);
|
|
97
|
+
return relatedRecords;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Builds a map of database records by primary key for efficient lookup
|
|
101
|
+
*/
|
|
102
|
+
buildDatabaseRecordMap(dbRecords) {
|
|
103
|
+
const dbRecordMap = new Map();
|
|
104
|
+
for (const relatedRecord of dbRecords) {
|
|
105
|
+
const relatedPrimaryKey = this.getRecordPrimaryKey(relatedRecord);
|
|
106
|
+
if (relatedPrimaryKey) {
|
|
107
|
+
dbRecordMap.set(relatedPrimaryKey, relatedRecord);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return dbRecordMap;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Processes existing related entities
|
|
114
|
+
*/
|
|
115
|
+
async processExistingRelatedEntities(existingRelatedEntities, dbRecordMap, relationConfig, relatedEntityConfig, processRecordData, currentDepth, ancestryPath, relatedRecords, processedIds, verbose) {
|
|
116
|
+
for (const existingRelatedEntity of existingRelatedEntities) {
|
|
117
|
+
const existingPrimaryKey = existingRelatedEntity.primaryKey?.ID;
|
|
118
|
+
if (!existingPrimaryKey) {
|
|
119
|
+
this.logWarning('Existing related entity missing primary key, skipping', verbose);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const dbRecord = dbRecordMap.get(existingPrimaryKey);
|
|
123
|
+
if (!dbRecord) {
|
|
124
|
+
if (verbose) {
|
|
125
|
+
console.log(`Related entity ${existingPrimaryKey} no longer exists in database, removing from results`);
|
|
126
|
+
}
|
|
127
|
+
continue; // Skip deleted records
|
|
128
|
+
}
|
|
129
|
+
const recordData = await this.processExistingRelatedEntity(dbRecord, existingPrimaryKey, relationConfig, relatedEntityConfig, processRecordData, existingRelatedEntity, currentDepth, ancestryPath, verbose);
|
|
130
|
+
if (recordData) {
|
|
131
|
+
relatedRecords.push(recordData);
|
|
132
|
+
processedIds.add(existingPrimaryKey);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Processes a single existing related entity
|
|
138
|
+
*/
|
|
139
|
+
async processExistingRelatedEntity(dbRecord, existingPrimaryKey, relationConfig, relatedEntityConfig, processRecordData, existingRelatedEntity, currentDepth, ancestryPath, verbose) {
|
|
140
|
+
const relatedRecordPrimaryKey = this.buildPrimaryKeyForRecord(existingPrimaryKey, dbRecord, relationConfig.entity);
|
|
141
|
+
const fieldOverrides = this.createFieldOverrides(dbRecord, relationConfig);
|
|
142
|
+
if (!fieldOverrides) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return await processRecordData(dbRecord, relatedRecordPrimaryKey, '', // targetDir not needed for related entities
|
|
146
|
+
relatedEntityConfig, verbose, false, // isNewRecord = false for existing records
|
|
147
|
+
existingRelatedEntity, // Pass existing data for change detection
|
|
148
|
+
currentDepth + 1, ancestryPath, fieldOverrides // Pass the field override for @parent:ID
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Processes new related entities
|
|
153
|
+
*/
|
|
154
|
+
async processNewRelatedEntities(dbRecords, relationConfig, relatedEntityConfig, processRecordData, currentDepth, ancestryPath, relatedRecords, processedIds, verbose) {
|
|
155
|
+
for (const relatedRecord of dbRecords) {
|
|
156
|
+
const relatedPrimaryKey = this.getRecordPrimaryKey(relatedRecord);
|
|
157
|
+
if (!relatedPrimaryKey || processedIds.has(relatedPrimaryKey)) {
|
|
158
|
+
continue; // Skip already processed records
|
|
159
|
+
}
|
|
160
|
+
const recordData = await this.processNewRelatedEntity(relatedRecord, relatedPrimaryKey, relationConfig, relatedEntityConfig, processRecordData, currentDepth, ancestryPath, verbose);
|
|
161
|
+
if (recordData) {
|
|
162
|
+
relatedRecords.push(recordData);
|
|
163
|
+
processedIds.add(relatedPrimaryKey);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Processes a single new related entity
|
|
169
|
+
*/
|
|
170
|
+
async processNewRelatedEntity(relatedRecord, relatedPrimaryKey, relationConfig, relatedEntityConfig, processRecordData, currentDepth, ancestryPath, verbose) {
|
|
171
|
+
const relatedRecordPrimaryKey = this.buildPrimaryKeyForRecord(relatedPrimaryKey, relatedRecord, relationConfig.entity);
|
|
172
|
+
const fieldOverrides = this.createFieldOverrides(relatedRecord, relationConfig);
|
|
173
|
+
if (!fieldOverrides) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return await processRecordData(relatedRecord, relatedRecordPrimaryKey, '', // targetDir not needed for related entities
|
|
177
|
+
relatedEntityConfig, verbose, true, // isNewRecord = true for new records
|
|
178
|
+
undefined, // No existing data for new records
|
|
179
|
+
currentDepth + 1, ancestryPath, fieldOverrides // Pass the field override for @parent:ID
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Creates field overrides for @parent:ID replacement
|
|
184
|
+
*/
|
|
185
|
+
createFieldOverrides(record, relationConfig) {
|
|
186
|
+
if (typeof record.GetAll === 'function') {
|
|
187
|
+
const data = record.GetAll();
|
|
188
|
+
if (data[relationConfig.foreignKey] !== undefined) {
|
|
189
|
+
return { [relationConfig.foreignKey]: '@parent:ID' };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
if (record[relationConfig.foreignKey] !== undefined) {
|
|
194
|
+
return { [relationConfig.foreignKey]: '@parent:ID' };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Builds primary key for a record
|
|
201
|
+
*/
|
|
202
|
+
buildPrimaryKeyForRecord(primaryKeyValue, record, entityName) {
|
|
203
|
+
const relatedRecordPrimaryKey = {};
|
|
204
|
+
const entityInfo = this.syncEngine.getEntityInfo(entityName);
|
|
205
|
+
for (const pk of entityInfo?.PrimaryKeys || []) {
|
|
206
|
+
if (pk.Name === 'ID') {
|
|
207
|
+
relatedRecordPrimaryKey[pk.Name] = primaryKeyValue;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// For compound keys, get the value from the related record
|
|
211
|
+
relatedRecordPrimaryKey[pk.Name] = this.getFieldValue(record, pk.Name);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return relatedRecordPrimaryKey;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Get the primary key value from a record
|
|
218
|
+
*/
|
|
219
|
+
getRecordPrimaryKey(record) {
|
|
220
|
+
if (!record)
|
|
221
|
+
return null;
|
|
222
|
+
// Try to get ID directly
|
|
223
|
+
if (record.ID)
|
|
224
|
+
return record.ID;
|
|
225
|
+
// Try to get from GetAll() method if it's an entity object
|
|
226
|
+
if (typeof record.GetAll === 'function') {
|
|
227
|
+
const data = record.GetAll();
|
|
228
|
+
if (data.ID)
|
|
229
|
+
return data.ID;
|
|
230
|
+
}
|
|
231
|
+
// Try common variations
|
|
232
|
+
if (record.id)
|
|
233
|
+
return record.id;
|
|
234
|
+
if (record.Id)
|
|
235
|
+
return record.Id;
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Get a field value from a record, handling both entity objects and plain objects
|
|
240
|
+
*/
|
|
241
|
+
getFieldValue(record, fieldName) {
|
|
242
|
+
if (!record)
|
|
243
|
+
return null;
|
|
244
|
+
// Try to get field directly using bracket notation with type assertion
|
|
245
|
+
if (record[fieldName] !== undefined)
|
|
246
|
+
return record[fieldName];
|
|
247
|
+
// Try to get from GetAll() method if it's an entity object
|
|
248
|
+
if (typeof record.GetAll === 'function') {
|
|
249
|
+
const data = record.GetAll();
|
|
250
|
+
if (data[fieldName] !== undefined)
|
|
251
|
+
return data[fieldName];
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Log warning message if verbose mode is enabled
|
|
257
|
+
*/
|
|
258
|
+
logWarning(message, verbose) {
|
|
259
|
+
if (verbose) {
|
|
260
|
+
console.warn(message);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Log error message if verbose mode is enabled
|
|
265
|
+
*/
|
|
266
|
+
logError(message, error, verbose) {
|
|
267
|
+
if (verbose) {
|
|
268
|
+
console.error(`${message}: ${error}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
exports.RelatedEntityHandler = RelatedEntityHandler;
|
|
273
|
+
//# sourceMappingURL=RelatedEntityHandler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RelatedEntityHandler.js","sourceRoot":"","sources":["../../src/lib/RelatedEntityHandler.ts"],"names":[],"mappings":"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","sourcesContent":["import { BaseEntity, RunView, UserInfo } from '@memberjunction/core';\nimport { SyncEngine, RecordData } from '../lib/sync-engine';\nimport { RelatedEntityConfig, EntityConfig } from '../config';\n\n/**\n * Handles loading and processing of related entities for records\n */\nexport class RelatedEntityHandler {\n constructor(\n private syncEngine: SyncEngine,\n private contextUser: UserInfo\n ) {}\n\n /**\n * Load related entities for a record\n */\n async loadRelatedEntities(\n parentRecord: BaseEntity,\n relationConfig: RelatedEntityConfig,\n parentEntityConfig: EntityConfig,\n existingRelatedEntities: RecordData[],\n processRecordData: (\n record: BaseEntity,\n primaryKey: Record<string, any>,\n targetDir: string,\n entityConfig: EntityConfig,\n verbose?: boolean,\n isNewRecord?: boolean,\n existingRecordData?: RecordData,\n currentDepth?: number,\n ancestryPath?: Set<string>,\n fieldOverrides?: Record<string, any>\n ) => Promise<RecordData>,\n currentDepth: number,\n ancestryPath: Set<string>,\n verbose?: boolean\n ): Promise<RecordData[]> {\n try {\n const parentPrimaryKey = this.getRecordPrimaryKey(parentRecord);\n if (!parentPrimaryKey) {\n this.logWarning('Unable to determine primary key for parent record', verbose);\n return [];\n }\n\n const relatedRecords = await this.queryRelatedEntities(\n parentPrimaryKey, \n relationConfig, \n verbose\n );\n\n if (!relatedRecords) {\n return [];\n }\n\n const relatedEntityConfig = this.createRelatedEntityConfig(relationConfig, parentEntityConfig);\n \n return await this.processRelatedRecords(\n relatedRecords,\n relationConfig,\n relatedEntityConfig,\n existingRelatedEntities,\n processRecordData,\n currentDepth,\n ancestryPath,\n verbose\n );\n } catch (error) {\n this.logError(`Error loading related entities for ${relationConfig.entity}`, error, verbose);\n return [];\n }\n }\n\n /**\n * Queries the database for related entities\n */\n private async queryRelatedEntities(\n parentPrimaryKey: string,\n relationConfig: RelatedEntityConfig,\n verbose?: boolean\n ): Promise<BaseEntity[] | null> {\n const filter = this.buildRelatedEntityFilter(parentPrimaryKey, relationConfig);\n \n if (verbose) {\n console.log(`Loading related entities: ${relationConfig.entity} with filter: ${filter}`);\n }\n\n const rv = new RunView();\n const result = await rv.RunView({\n EntityName: relationConfig.entity,\n ExtraFilter: filter,\n ResultType: 'entity_object'\n }, this.contextUser);\n\n if (!result.Success) {\n this.logWarning(`Failed to load related entities ${relationConfig.entity}: ${result.ErrorMessage}`, verbose);\n return null;\n }\n\n if (verbose) {\n console.log(`Found ${result.Results.length} related records for ${relationConfig.entity}`);\n }\n\n return result.Results;\n }\n\n /**\n * Builds the filter for querying related entities\n */\n private buildRelatedEntityFilter(parentPrimaryKey: string, relationConfig: RelatedEntityConfig): string {\n let filter = `${relationConfig.foreignKey} = '${parentPrimaryKey}'`;\n if (relationConfig.filter) {\n filter += ` AND (${relationConfig.filter})`;\n }\n return filter;\n }\n\n /**\n * Creates entity config for related entity processing\n */\n private createRelatedEntityConfig(\n relationConfig: RelatedEntityConfig, \n parentEntityConfig: EntityConfig\n ): EntityConfig {\n return {\n entity: relationConfig.entity,\n pull: {\n excludeFields: relationConfig.excludeFields || [],\n lookupFields: relationConfig.lookupFields || {},\n externalizeFields: relationConfig.externalizeFields || [],\n relatedEntities: relationConfig.relatedEntities || {},\n ignoreVirtualFields: parentEntityConfig.pull?.ignoreVirtualFields || false,\n ignoreNullFields: parentEntityConfig.pull?.ignoreNullFields || false\n }\n };\n }\n\n /**\n * Processes all related records (both existing and new)\n */\n private async processRelatedRecords(\n dbRecords: BaseEntity[],\n relationConfig: RelatedEntityConfig,\n relatedEntityConfig: EntityConfig,\n existingRelatedEntities: RecordData[],\n processRecordData: Function,\n currentDepth: number,\n ancestryPath: Set<string>,\n verbose?: boolean\n ): Promise<RecordData[]> {\n const dbRecordMap = this.buildDatabaseRecordMap(dbRecords);\n const relatedRecords: RecordData[] = [];\n const processedIds = new Set<string>();\n\n // Process existing related entities first (preserving order)\n await this.processExistingRelatedEntities(\n existingRelatedEntities,\n dbRecordMap,\n relationConfig,\n relatedEntityConfig,\n processRecordData,\n currentDepth,\n ancestryPath,\n relatedRecords,\n processedIds,\n verbose\n );\n\n // Process new related entities (append to end)\n await this.processNewRelatedEntities(\n dbRecords,\n relationConfig,\n relatedEntityConfig,\n processRecordData,\n currentDepth,\n ancestryPath,\n relatedRecords,\n processedIds,\n verbose\n );\n\n return relatedRecords;\n }\n\n /**\n * Builds a map of database records by primary key for efficient lookup\n */\n private buildDatabaseRecordMap(dbRecords: BaseEntity[]): Map<string, BaseEntity> {\n const dbRecordMap = new Map<string, BaseEntity>();\n \n for (const relatedRecord of dbRecords) {\n const relatedPrimaryKey = this.getRecordPrimaryKey(relatedRecord);\n if (relatedPrimaryKey) {\n dbRecordMap.set(relatedPrimaryKey, relatedRecord);\n }\n }\n \n return dbRecordMap;\n }\n\n /**\n * Processes existing related entities\n */\n private async processExistingRelatedEntities(\n existingRelatedEntities: RecordData[],\n dbRecordMap: Map<string, BaseEntity>,\n relationConfig: RelatedEntityConfig,\n relatedEntityConfig: EntityConfig,\n processRecordData: Function,\n currentDepth: number,\n ancestryPath: Set<string>,\n relatedRecords: RecordData[],\n processedIds: Set<string>,\n verbose?: boolean\n ): Promise<void> {\n for (const existingRelatedEntity of existingRelatedEntities) {\n const existingPrimaryKey = existingRelatedEntity.primaryKey?.ID;\n if (!existingPrimaryKey) {\n this.logWarning('Existing related entity missing primary key, skipping', verbose);\n continue;\n }\n\n const dbRecord = dbRecordMap.get(existingPrimaryKey);\n if (!dbRecord) {\n if (verbose) {\n console.log(`Related entity ${existingPrimaryKey} no longer exists in database, removing from results`);\n }\n continue; // Skip deleted records\n }\n\n const recordData = await this.processExistingRelatedEntity(\n dbRecord,\n existingPrimaryKey,\n relationConfig,\n relatedEntityConfig,\n processRecordData,\n existingRelatedEntity,\n currentDepth,\n ancestryPath,\n verbose\n );\n\n if (recordData) {\n relatedRecords.push(recordData);\n processedIds.add(existingPrimaryKey);\n }\n }\n }\n\n /**\n * Processes a single existing related entity\n */\n private async processExistingRelatedEntity(\n dbRecord: BaseEntity,\n existingPrimaryKey: string,\n relationConfig: RelatedEntityConfig,\n relatedEntityConfig: EntityConfig,\n processRecordData: Function,\n existingRelatedEntity: RecordData,\n currentDepth: number,\n ancestryPath: Set<string>,\n verbose?: boolean\n ): Promise<RecordData | null> {\n const relatedRecordPrimaryKey = this.buildPrimaryKeyForRecord(\n existingPrimaryKey, \n dbRecord, \n relationConfig.entity\n );\n\n const fieldOverrides = this.createFieldOverrides(dbRecord, relationConfig);\n if (!fieldOverrides) {\n return null;\n }\n\n return await processRecordData(\n dbRecord,\n relatedRecordPrimaryKey,\n '', // targetDir not needed for related entities\n relatedEntityConfig,\n verbose,\n false, // isNewRecord = false for existing records\n existingRelatedEntity, // Pass existing data for change detection\n currentDepth + 1,\n ancestryPath,\n fieldOverrides // Pass the field override for @parent:ID\n );\n }\n\n /**\n * Processes new related entities\n */\n private async processNewRelatedEntities(\n dbRecords: BaseEntity[],\n relationConfig: RelatedEntityConfig,\n relatedEntityConfig: EntityConfig,\n processRecordData: Function,\n currentDepth: number,\n ancestryPath: Set<string>,\n relatedRecords: RecordData[],\n processedIds: Set<string>,\n verbose?: boolean\n ): Promise<void> {\n for (const relatedRecord of dbRecords) {\n const relatedPrimaryKey = this.getRecordPrimaryKey(relatedRecord);\n if (!relatedPrimaryKey || processedIds.has(relatedPrimaryKey)) {\n continue; // Skip already processed records\n }\n\n const recordData = await this.processNewRelatedEntity(\n relatedRecord,\n relatedPrimaryKey,\n relationConfig,\n relatedEntityConfig,\n processRecordData,\n currentDepth,\n ancestryPath,\n verbose\n );\n\n if (recordData) {\n relatedRecords.push(recordData);\n processedIds.add(relatedPrimaryKey);\n }\n }\n }\n\n /**\n * Processes a single new related entity\n */\n private async processNewRelatedEntity(\n relatedRecord: BaseEntity,\n relatedPrimaryKey: string,\n relationConfig: RelatedEntityConfig,\n relatedEntityConfig: EntityConfig,\n processRecordData: Function,\n currentDepth: number,\n ancestryPath: Set<string>,\n verbose?: boolean\n ): Promise<RecordData | null> {\n const relatedRecordPrimaryKey = this.buildPrimaryKeyForRecord(\n relatedPrimaryKey, \n relatedRecord, \n relationConfig.entity\n );\n\n const fieldOverrides = this.createFieldOverrides(relatedRecord, relationConfig);\n if (!fieldOverrides) {\n return null;\n }\n\n return await processRecordData(\n relatedRecord,\n relatedRecordPrimaryKey,\n '', // targetDir not needed for related entities\n relatedEntityConfig,\n verbose,\n true, // isNewRecord = true for new records\n undefined, // No existing data for new records\n currentDepth + 1,\n ancestryPath,\n fieldOverrides // Pass the field override for @parent:ID\n );\n }\n\n /**\n * Creates field overrides for @parent:ID replacement\n */\n private createFieldOverrides(record: BaseEntity, relationConfig: RelatedEntityConfig): Record<string, any> | null {\n if (typeof record.GetAll === 'function') {\n const data = record.GetAll();\n if (data[relationConfig.foreignKey] !== undefined) {\n return { [relationConfig.foreignKey]: '@parent:ID' };\n }\n } else {\n if ((record as any)[relationConfig.foreignKey] !== undefined) {\n return { [relationConfig.foreignKey]: '@parent:ID' };\n }\n }\n return null;\n }\n\n /**\n * Builds primary key for a record\n */\n private buildPrimaryKeyForRecord(\n primaryKeyValue: string, \n record: BaseEntity, \n entityName: string\n ): Record<string, any> {\n const relatedRecordPrimaryKey: Record<string, any> = {};\n const entityInfo = this.syncEngine.getEntityInfo(entityName);\n \n for (const pk of entityInfo?.PrimaryKeys || []) {\n if (pk.Name === 'ID') {\n relatedRecordPrimaryKey[pk.Name] = primaryKeyValue;\n } else {\n // For compound keys, get the value from the related record\n relatedRecordPrimaryKey[pk.Name] = this.getFieldValue(record, pk.Name);\n }\n }\n \n return relatedRecordPrimaryKey;\n }\n\n /**\n * Get the primary key value from a record\n */\n private getRecordPrimaryKey(record: BaseEntity): string | null {\n if (!record) return null;\n \n // Try to get ID directly\n if ((record as any).ID) return (record as any).ID;\n \n // Try to get from GetAll() method if it's an entity object\n if (typeof record.GetAll === 'function') {\n const data = record.GetAll();\n if (data.ID) return data.ID;\n }\n \n // Try common variations\n if ((record as any).id) return (record as any).id;\n if ((record as any).Id) return (record as any).Id;\n \n return null;\n }\n\n /**\n * Get a field value from a record, handling both entity objects and plain objects\n */\n private getFieldValue(record: BaseEntity, fieldName: string): any {\n if (!record) return null;\n \n // Try to get field directly using bracket notation with type assertion\n if ((record as any)[fieldName] !== undefined) return (record as any)[fieldName];\n \n // Try to get from GetAll() method if it's an entity object\n if (typeof record.GetAll === 'function') {\n const data = record.GetAll();\n if (data[fieldName] !== undefined) return data[fieldName];\n }\n \n return null;\n }\n\n /**\n * Log warning message if verbose mode is enabled\n */\n private logWarning(message: string, verbose?: boolean): void {\n if (verbose) {\n console.warn(message);\n }\n }\n\n /**\n * Log error message if verbose mode is enabled\n */\n private logError(message: string, error: any, verbose?: boolean): void {\n if (verbose) {\n console.error(`${message}: ${error}`);\n }\n }\n}"]}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { RecordData } from './sync-engine';
|
|
2
|
+
/**
|
|
3
|
+
* Batches file write operations to improve performance and ensure consistent property ordering.
|
|
4
|
+
* Collects all changes during processing and writes each file only once at the end.
|
|
5
|
+
*/
|
|
6
|
+
export declare class FileWriteBatch {
|
|
7
|
+
private changes;
|
|
8
|
+
private fileContents;
|
|
9
|
+
/**
|
|
10
|
+
* Queue a complete file write operation
|
|
11
|
+
* @param filePath - Path to the file
|
|
12
|
+
* @param data - RecordData or array of RecordData to write
|
|
13
|
+
*/
|
|
14
|
+
queueWrite(filePath: string, data: RecordData | RecordData[]): void;
|
|
15
|
+
/**
|
|
16
|
+
* Queue an array record update operation
|
|
17
|
+
* @param filePath - Path to the file containing the array
|
|
18
|
+
* @param updatedRecord - The updated record data
|
|
19
|
+
* @param primaryKeyLookup - Primary key lookup string to identify the record
|
|
20
|
+
*/
|
|
21
|
+
queueArrayUpdate(filePath: string, updatedRecord: RecordData, primaryKeyLookup: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Queue a single record update operation
|
|
24
|
+
* @param filePath - Path to the file
|
|
25
|
+
* @param updatedRecord - The updated record data
|
|
26
|
+
*/
|
|
27
|
+
queueSingleUpdate(filePath: string, updatedRecord: RecordData): void;
|
|
28
|
+
/**
|
|
29
|
+
* Load and cache file contents if not already loaded
|
|
30
|
+
*/
|
|
31
|
+
private ensureFileLoaded;
|
|
32
|
+
/**
|
|
33
|
+
* Apply all queued changes to in-memory file contents
|
|
34
|
+
*/
|
|
35
|
+
private applyChanges;
|
|
36
|
+
/**
|
|
37
|
+
* Write all batched changes to files using JsonWriteHelper for consistent ordering
|
|
38
|
+
* @returns Number of files written
|
|
39
|
+
*/
|
|
40
|
+
flush(): Promise<number>;
|
|
41
|
+
/**
|
|
42
|
+
* Clear all batched changes without writing
|
|
43
|
+
*/
|
|
44
|
+
clear(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Get the number of files that will be written
|
|
47
|
+
*/
|
|
48
|
+
getPendingFileCount(): number;
|
|
49
|
+
/**
|
|
50
|
+
* Get all pending file paths
|
|
51
|
+
*/
|
|
52
|
+
getPendingFiles(): string[];
|
|
53
|
+
/**
|
|
54
|
+
* Add a change to the batch
|
|
55
|
+
*/
|
|
56
|
+
private addChange;
|
|
57
|
+
/**
|
|
58
|
+
* Create a primary key lookup string (same logic as PullService)
|
|
59
|
+
*/
|
|
60
|
+
private createPrimaryKeyLookup;
|
|
61
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.FileWriteBatch = void 0;
|
|
7
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const json_write_helper_1 = require("./json-write-helper");
|
|
10
|
+
/**
|
|
11
|
+
* Batches file write operations to improve performance and ensure consistent property ordering.
|
|
12
|
+
* Collects all changes during processing and writes each file only once at the end.
|
|
13
|
+
*/
|
|
14
|
+
class FileWriteBatch {
|
|
15
|
+
changes = new Map();
|
|
16
|
+
fileContents = new Map();
|
|
17
|
+
/**
|
|
18
|
+
* Queue a complete file write operation
|
|
19
|
+
* @param filePath - Path to the file
|
|
20
|
+
* @param data - RecordData or array of RecordData to write
|
|
21
|
+
*/
|
|
22
|
+
queueWrite(filePath, data) {
|
|
23
|
+
const absolutePath = path_1.default.resolve(filePath);
|
|
24
|
+
this.addChange(absolutePath, {
|
|
25
|
+
filePath: absolutePath,
|
|
26
|
+
operation: 'write',
|
|
27
|
+
data
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Queue an array record update operation
|
|
32
|
+
* @param filePath - Path to the file containing the array
|
|
33
|
+
* @param updatedRecord - The updated record data
|
|
34
|
+
* @param primaryKeyLookup - Primary key lookup string to identify the record
|
|
35
|
+
*/
|
|
36
|
+
queueArrayUpdate(filePath, updatedRecord, primaryKeyLookup) {
|
|
37
|
+
const absolutePath = path_1.default.resolve(filePath);
|
|
38
|
+
this.addChange(absolutePath, {
|
|
39
|
+
filePath: absolutePath,
|
|
40
|
+
operation: 'update-array',
|
|
41
|
+
data: updatedRecord,
|
|
42
|
+
primaryKeyLookup
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Queue a single record update operation
|
|
47
|
+
* @param filePath - Path to the file
|
|
48
|
+
* @param updatedRecord - The updated record data
|
|
49
|
+
*/
|
|
50
|
+
queueSingleUpdate(filePath, updatedRecord) {
|
|
51
|
+
const absolutePath = path_1.default.resolve(filePath);
|
|
52
|
+
this.addChange(absolutePath, {
|
|
53
|
+
filePath: absolutePath,
|
|
54
|
+
operation: 'update-single',
|
|
55
|
+
data: updatedRecord
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Load and cache file contents if not already loaded
|
|
60
|
+
*/
|
|
61
|
+
async ensureFileLoaded(filePath) {
|
|
62
|
+
if (this.fileContents.has(filePath)) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
if (await fs_extra_1.default.pathExists(filePath)) {
|
|
67
|
+
const content = await fs_extra_1.default.readJson(filePath);
|
|
68
|
+
this.fileContents.set(filePath, content);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// If file doesn't exist or can't be read, we'll create it fresh
|
|
73
|
+
// Don't throw here - let the calling code handle it
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Apply all queued changes to in-memory file contents
|
|
78
|
+
*/
|
|
79
|
+
async applyChanges() {
|
|
80
|
+
for (const [filePath, changes] of this.changes) {
|
|
81
|
+
await this.ensureFileLoaded(filePath);
|
|
82
|
+
let currentContent = this.fileContents.get(filePath) || [];
|
|
83
|
+
for (const change of changes) {
|
|
84
|
+
switch (change.operation) {
|
|
85
|
+
case 'write':
|
|
86
|
+
// Complete overwrite
|
|
87
|
+
currentContent = change.data;
|
|
88
|
+
break;
|
|
89
|
+
case 'update-array':
|
|
90
|
+
// Update a specific record in an array
|
|
91
|
+
if (Array.isArray(currentContent) && change.primaryKeyLookup) {
|
|
92
|
+
const index = currentContent.findIndex(r => this.createPrimaryKeyLookup(r.primaryKey || {}) === change.primaryKeyLookup);
|
|
93
|
+
if (index >= 0) {
|
|
94
|
+
currentContent[index] = change.data;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Record not found, append it
|
|
98
|
+
currentContent.push(change.data);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// File doesn't contain an array, make it one
|
|
103
|
+
currentContent = [change.data];
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
case 'update-single':
|
|
107
|
+
// Replace the entire file content with a single record
|
|
108
|
+
currentContent = change.data;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Update the in-memory content
|
|
113
|
+
if (currentContent !== undefined) {
|
|
114
|
+
this.fileContents.set(filePath, currentContent);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Write all batched changes to files using JsonWriteHelper for consistent ordering
|
|
120
|
+
* @returns Number of files written
|
|
121
|
+
*/
|
|
122
|
+
async flush() {
|
|
123
|
+
if (this.changes.size === 0) {
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
// Apply all changes to in-memory content first
|
|
127
|
+
await this.applyChanges();
|
|
128
|
+
// Write all files using JsonWriteHelper
|
|
129
|
+
const writePromises = [];
|
|
130
|
+
for (const [filePath, content] of this.fileContents.entries()) {
|
|
131
|
+
// Ensure directory exists
|
|
132
|
+
const dir = path_1.default.dirname(filePath);
|
|
133
|
+
await fs_extra_1.default.ensureDir(dir);
|
|
134
|
+
// Write using JsonWriteHelper for consistent property ordering
|
|
135
|
+
writePromises.push(json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(filePath, content));
|
|
136
|
+
}
|
|
137
|
+
await Promise.all(writePromises);
|
|
138
|
+
const filesWritten = this.fileContents.size;
|
|
139
|
+
// Clear all batched changes
|
|
140
|
+
this.clear();
|
|
141
|
+
return filesWritten;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Clear all batched changes without writing
|
|
145
|
+
*/
|
|
146
|
+
clear() {
|
|
147
|
+
this.changes.clear();
|
|
148
|
+
this.fileContents.clear();
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Get the number of files that will be written
|
|
152
|
+
*/
|
|
153
|
+
getPendingFileCount() {
|
|
154
|
+
return this.changes.size;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Get all pending file paths
|
|
158
|
+
*/
|
|
159
|
+
getPendingFiles() {
|
|
160
|
+
return Array.from(this.changes.keys());
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Add a change to the batch
|
|
164
|
+
*/
|
|
165
|
+
addChange(filePath, change) {
|
|
166
|
+
if (!this.changes.has(filePath)) {
|
|
167
|
+
this.changes.set(filePath, []);
|
|
168
|
+
}
|
|
169
|
+
this.changes.get(filePath).push(change);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Create a primary key lookup string (same logic as PullService)
|
|
173
|
+
*/
|
|
174
|
+
createPrimaryKeyLookup(primaryKey) {
|
|
175
|
+
const keys = Object.keys(primaryKey).sort();
|
|
176
|
+
return keys.map(k => `${k}:${primaryKey[k]}`).join('|');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
exports.FileWriteBatch = FileWriteBatch;
|
|
180
|
+
//# sourceMappingURL=file-write-batch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-write-batch.js","sourceRoot":"","sources":["../../src/lib/file-write-batch.ts"],"names":[],"mappings":"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","sourcesContent":["import fs from 'fs-extra';\nimport path from 'path';\nimport { RecordData } from './sync-engine';\nimport { JsonWriteHelper } from './json-write-helper';\n\n/**\n * Represents a pending change to a file\n */\ninterface FileChange {\n /** Path to the file */\n filePath: string;\n /** Type of change operation */\n operation: 'write' | 'update-array' | 'update-single';\n /** The data to write */\n data: RecordData | RecordData[];\n /** For array updates: the index of the record to update */\n arrayIndex?: number;\n /** For updates: the primary key lookup to identify the record */\n primaryKeyLookup?: string;\n}\n\n/**\n * Batches file write operations to improve performance and ensure consistent property ordering.\n * Collects all changes during processing and writes each file only once at the end.\n */\nexport class FileWriteBatch {\n private changes = new Map<string, FileChange[]>();\n private fileContents = new Map<string, RecordData | RecordData[]>();\n \n /**\n * Queue a complete file write operation\n * @param filePath - Path to the file\n * @param data - RecordData or array of RecordData to write\n */\n queueWrite(filePath: string, data: RecordData | RecordData[]): void {\n const absolutePath = path.resolve(filePath);\n this.addChange(absolutePath, {\n filePath: absolutePath,\n operation: 'write',\n data\n });\n }\n \n /**\n * Queue an array record update operation\n * @param filePath - Path to the file containing the array\n * @param updatedRecord - The updated record data\n * @param primaryKeyLookup - Primary key lookup string to identify the record\n */\n queueArrayUpdate(filePath: string, updatedRecord: RecordData, primaryKeyLookup: string): void {\n const absolutePath = path.resolve(filePath);\n this.addChange(absolutePath, {\n filePath: absolutePath,\n operation: 'update-array',\n data: updatedRecord,\n primaryKeyLookup\n });\n }\n \n /**\n * Queue a single record update operation\n * @param filePath - Path to the file\n * @param updatedRecord - The updated record data\n */\n queueSingleUpdate(filePath: string, updatedRecord: RecordData): void {\n const absolutePath = path.resolve(filePath);\n this.addChange(absolutePath, {\n filePath: absolutePath,\n operation: 'update-single',\n data: updatedRecord\n });\n }\n \n /**\n * Load and cache file contents if not already loaded\n */\n private async ensureFileLoaded(filePath: string): Promise<void> {\n if (this.fileContents.has(filePath)) {\n return;\n }\n \n try {\n if (await fs.pathExists(filePath)) {\n const content = await fs.readJson(filePath);\n this.fileContents.set(filePath, content);\n }\n } catch (error) {\n // If file doesn't exist or can't be read, we'll create it fresh\n // Don't throw here - let the calling code handle it\n }\n }\n \n /**\n * Apply all queued changes to in-memory file contents\n */\n private async applyChanges(): Promise<void> {\n for (const [filePath, changes] of this.changes) {\n await this.ensureFileLoaded(filePath);\n \n let currentContent: RecordData | RecordData[] = this.fileContents.get(filePath) || [];\n \n for (const change of changes) {\n switch (change.operation) {\n case 'write':\n // Complete overwrite\n currentContent = change.data;\n break;\n \n case 'update-array':\n // Update a specific record in an array\n if (Array.isArray(currentContent) && change.primaryKeyLookup) {\n const index = currentContent.findIndex(r => \n this.createPrimaryKeyLookup(r.primaryKey || {}) === change.primaryKeyLookup\n );\n \n if (index >= 0) {\n currentContent[index] = change.data as RecordData;\n } else {\n // Record not found, append it\n currentContent.push(change.data as RecordData);\n }\n } else {\n // File doesn't contain an array, make it one\n currentContent = [change.data as RecordData];\n }\n break;\n \n case 'update-single':\n // Replace the entire file content with a single record\n currentContent = change.data;\n break;\n }\n }\n \n // Update the in-memory content\n if (currentContent !== undefined) {\n this.fileContents.set(filePath, currentContent);\n }\n }\n }\n \n /**\n * Write all batched changes to files using JsonWriteHelper for consistent ordering\n * @returns Number of files written\n */\n async flush(): Promise<number> {\n if (this.changes.size === 0) {\n return 0;\n }\n \n // Apply all changes to in-memory content first\n await this.applyChanges();\n \n // Write all files using JsonWriteHelper\n const writePromises: Promise<void>[] = [];\n \n for (const [filePath, content] of this.fileContents.entries()) {\n // Ensure directory exists\n const dir = path.dirname(filePath);\n await fs.ensureDir(dir);\n \n // Write using JsonWriteHelper for consistent property ordering\n writePromises.push(\n JsonWriteHelper.writeOrderedRecordData(filePath, content)\n );\n }\n \n await Promise.all(writePromises);\n \n const filesWritten = this.fileContents.size;\n \n // Clear all batched changes\n this.clear();\n \n return filesWritten;\n }\n \n /**\n * Clear all batched changes without writing\n */\n clear(): void {\n this.changes.clear();\n this.fileContents.clear();\n }\n \n /**\n * Get the number of files that will be written\n */\n getPendingFileCount(): number {\n return this.changes.size;\n }\n \n /**\n * Get all pending file paths\n */\n getPendingFiles(): string[] {\n return Array.from(this.changes.keys());\n }\n \n /**\n * Add a change to the batch\n */\n private addChange(filePath: string, change: FileChange): void {\n if (!this.changes.has(filePath)) {\n this.changes.set(filePath, []);\n }\n this.changes.get(filePath)!.push(change);\n }\n \n /**\n * Create a primary key lookup string (same logic as PullService)\n */\n private createPrimaryKeyLookup(primaryKey: Record<string, any>): string {\n const keys = Object.keys(primaryKey).sort();\n return keys.map(k => `${k}:${primaryKey[k]}`).join('|');\n }\n}"]}
|