@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
|
@@ -8,6 +8,8 @@ import { FileWriteBatch } from '../lib/file-write-batch.js';
|
|
|
8
8
|
import { RecordProcessor } from '../lib/RecordProcessor.js';
|
|
9
9
|
import { createPrimaryKeyLookup, extractPrimaryKeyValues } from '../lib/record-primary-key.js';
|
|
10
10
|
import { DescribeMissingEntitySubclass } from '../lib/entity-subclass-guard.js';
|
|
11
|
+
import { ApplyPullOverrides } from '../lib/pull-option-overrides.js';
|
|
12
|
+
import { ExistingRecordIndex, UnsetPrimaryKeyVariableError } from '../lib/existing-record-index.js';
|
|
11
13
|
/**
|
|
12
14
|
* Resolves `pull.backupDirectory` against the directory of the file being backed up (default
|
|
13
15
|
* `.backups`). An absolute path is taken as relative to that directory, as `path.join` always has.
|
|
@@ -57,6 +59,7 @@ export class PullService {
|
|
|
57
59
|
this.createdBackupFiles = [];
|
|
58
60
|
this.createdBackupDirs.clear();
|
|
59
61
|
this.backupSources.clear();
|
|
62
|
+
this.recordProcessor.WarningSink = (message) => callbacks?.onWarn?.(`⚠️ ${message}`);
|
|
60
63
|
let targetDir;
|
|
61
64
|
let entityConfig;
|
|
62
65
|
// Check if we should use a specific target directory
|
|
@@ -94,6 +97,7 @@ export class PullService {
|
|
|
94
97
|
throw new Error(`Invalid entity configuration in ${targetDir}`);
|
|
95
98
|
}
|
|
96
99
|
}
|
|
100
|
+
entityConfig = ApplyPullOverrides(entityConfig, this.pullOverridesFor(options));
|
|
97
101
|
// Show configuration notice only if relevant and in verbose mode
|
|
98
102
|
if (options.verbose && entityConfig?.pull?.appendRecordsToExistingFile && entityConfig?.pull?.newFileName) {
|
|
99
103
|
const targetFile = path.join(targetDir, entityConfig.pull.newFileName.endsWith('.json')
|
|
@@ -157,7 +161,7 @@ export class PullService {
|
|
|
157
161
|
// Process records with error handling and rollback
|
|
158
162
|
let pullResult;
|
|
159
163
|
try {
|
|
160
|
-
pullResult = await this.processRecords(result.Results, options, targetDir, entityConfig, callbacks);
|
|
164
|
+
pullResult = await this.processRecords(result.Results, options, targetDir, entityConfig, callbacks, sinceTimestamp);
|
|
161
165
|
// Write all batched file changes at once
|
|
162
166
|
if (!options.dryRun) {
|
|
163
167
|
// Back up every existing file this pull is about to rewrite — updated and appended-to
|
|
@@ -180,8 +184,10 @@ export class PullService {
|
|
|
180
184
|
// Attempt to rollback file changes if not in dry run mode
|
|
181
185
|
if (!options.dryRun) {
|
|
182
186
|
try {
|
|
183
|
-
await this.rollbackFileChanges(callbacks);
|
|
184
|
-
|
|
187
|
+
const restored = await this.rollbackFileChanges(callbacks);
|
|
188
|
+
if (restored > 0) {
|
|
189
|
+
callbacks?.onWarn?.('File changes have been rolled back due to operation failure');
|
|
190
|
+
}
|
|
185
191
|
}
|
|
186
192
|
catch (rollbackError) {
|
|
187
193
|
callbacks?.onError?.(`Rollback failed: ${rollbackError.message || rollbackError}`);
|
|
@@ -192,7 +198,8 @@ export class PullService {
|
|
|
192
198
|
// Incremental pull: detect soft-deleted records and remove their local files.
|
|
193
199
|
// Records deleted via __mj_DeletedAt won't appear in the UpdatedAt query,
|
|
194
200
|
// so we need a separate check.
|
|
195
|
-
|
|
201
|
+
// A multi-file pull removes soft-deleted entries from the file itself
|
|
202
|
+
if (sinceTimestamp && !options.dryRun && !options.multiFile) {
|
|
196
203
|
await this.removeDeletedRecordFiles(options, sinceTimestamp, targetDir, callbacks);
|
|
197
204
|
}
|
|
198
205
|
// Persist incremental state after a successful pull
|
|
@@ -205,11 +212,21 @@ export class PullService {
|
|
|
205
212
|
targetDir
|
|
206
213
|
};
|
|
207
214
|
}
|
|
215
|
+
/** The CLI overrides of one pull; the update/create options keep their option names. */
|
|
216
|
+
pullOverridesFor(options) {
|
|
217
|
+
return {
|
|
218
|
+
MergeStrategy: options.MergeStrategy,
|
|
219
|
+
PreserveFields: options.PreserveFields,
|
|
220
|
+
ExcludeFields: options.ExcludeFields,
|
|
221
|
+
UpdateExistingRecords: options.updateExistingRecords,
|
|
222
|
+
CreateNewFileIfNotFound: options.createNewFileIfNotFound,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
208
225
|
/** @deprecated Use {@link Pull}. */
|
|
209
226
|
async pull(options, callbacks) {
|
|
210
227
|
return this.Pull(options, callbacks);
|
|
211
228
|
}
|
|
212
|
-
async processRecords(records, options, targetDir, entityConfig, callbacks) {
|
|
229
|
+
async processRecords(records, options, targetDir, entityConfig, callbacks, sinceTimestamp) {
|
|
213
230
|
const entityInfo = this.syncEngine.getEntityInfo(options.entity);
|
|
214
231
|
if (!entityInfo) {
|
|
215
232
|
throw new Error(`Entity information not found for: ${options.entity}`);
|
|
@@ -223,38 +240,64 @@ export class PullService {
|
|
|
223
240
|
const batchedRelatedData = await this.prefetchRelatedEntities(records, entityConfig, entityInfo, options.verbose, callbacks);
|
|
224
241
|
// If multi-file flag is set, collect all records
|
|
225
242
|
if (options.multiFile) {
|
|
243
|
+
const fileName = options.multiFile.endsWith('.json') ? options.multiFile : `${options.multiFile}.json`;
|
|
244
|
+
const filePath = path.join(targetDir, fileName);
|
|
245
|
+
const existingRecords = await this.readMultiFile(filePath);
|
|
246
|
+
const existingIndex = await this.buildMultiFileIndex(filePath, existingRecords, targetDir, entityConfig, entityInfo);
|
|
247
|
+
existingIndex.ThrowIfUnsetKeyVariables(options.entity);
|
|
226
248
|
const allRecords = [];
|
|
249
|
+
const matched = new Map();
|
|
227
250
|
const errors = [];
|
|
251
|
+
const unsetKeyErrors = [];
|
|
228
252
|
// Process records in parallel for multi-file mode
|
|
229
253
|
const recordPromises = records.map(async (record, index) => {
|
|
230
254
|
try {
|
|
231
255
|
const primaryKey = extractPrimaryKeyValues(record, entityInfo);
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
256
|
+
const found = existingIndex.Find(primaryKey);
|
|
257
|
+
const existing = found?.Record;
|
|
258
|
+
// Only an entry of the target file is replaced in place; a record from another file is added
|
|
259
|
+
const targetEntry = found?.FilePath === filePath ? existing : undefined;
|
|
260
|
+
const recordData = await this.recordProcessor.processRecord(record, primaryKey, targetDir, entityConfig, options.verbose, !existing, existing, 0, new Set(), undefined, batchedRelatedData);
|
|
261
|
+
return { success: true, recordData, existing: targetEntry, index };
|
|
235
262
|
}
|
|
236
263
|
catch (error) {
|
|
237
|
-
|
|
264
|
+
if (this.keepUnsetKeyError(error, index, filePath, options.entity, unsetKeyErrors)) {
|
|
265
|
+
return { success: false, recordData: null, existing: undefined, index };
|
|
266
|
+
}
|
|
267
|
+
const errorMessage = `Failed to process record ${index + 1}: ${error instanceof Error ? error.message : String(error)}`;
|
|
238
268
|
errors.push(errorMessage);
|
|
239
269
|
callbacks?.onWarn?.(errorMessage);
|
|
240
|
-
return { success: false, recordData: null, index };
|
|
270
|
+
return { success: false, recordData: null, existing: undefined, index };
|
|
241
271
|
}
|
|
242
272
|
});
|
|
243
273
|
const recordResults = await Promise.all(recordPromises);
|
|
274
|
+
this.throwFirstUnsetKeyError(unsetKeyErrors);
|
|
244
275
|
// Collect successful records
|
|
245
276
|
for (const result of recordResults) {
|
|
246
277
|
if (result.success && result.recordData) {
|
|
247
278
|
allRecords.push(result.recordData);
|
|
279
|
+
if (result.existing) {
|
|
280
|
+
matched.set(result.existing, result.recordData);
|
|
281
|
+
}
|
|
248
282
|
processed++;
|
|
249
283
|
}
|
|
250
284
|
}
|
|
251
285
|
if (options.verbose) {
|
|
252
286
|
callbacks?.onProgress?.(`Processed ${processed}/${records.length} records in parallel`);
|
|
253
287
|
}
|
|
254
|
-
//
|
|
255
|
-
if (
|
|
256
|
-
const
|
|
257
|
-
|
|
288
|
+
// A full pull writes the query result; an incremental pull updates the existing file
|
|
289
|
+
if (sinceTimestamp) {
|
|
290
|
+
const deleted = await this.softDeletedMultiFileEntries(options, sinceTimestamp, existingIndex, filePath, entityInfo, callbacks);
|
|
291
|
+
if (allRecords.length > 0 || deleted.size > 0) {
|
|
292
|
+
const kept = existingRecords.filter((existing) => !deleted.has(existing));
|
|
293
|
+
this.fileWriteBatch.queueWrite(filePath, this.upsertMultiFileRecords(kept, matched, allRecords));
|
|
294
|
+
callbacks?.onSuccess?.(`Queued ${processed} records for ${path.basename(filePath)}`);
|
|
295
|
+
}
|
|
296
|
+
if (deleted.size > 0) {
|
|
297
|
+
callbacks?.onSuccess?.(`Removed ${deleted.size} entries for soft-deleted records from ${path.basename(filePath)}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else if (allRecords.length > 0) {
|
|
258
301
|
this.fileWriteBatch.queueWrite(filePath, allRecords);
|
|
259
302
|
callbacks?.onSuccess?.(`Queued ${processed} records for ${path.basename(filePath)}`);
|
|
260
303
|
}
|
|
@@ -282,6 +325,98 @@ export class PullService {
|
|
|
282
325
|
}
|
|
283
326
|
return { processed, created, updated, skipped };
|
|
284
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* The existing-record index of a multi-file pull: the target file's entries first, then the records
|
|
330
|
+
* of the folder's other record files (found as a single-file pull finds them), so every `@env:`
|
|
331
|
+
* reference is kept.
|
|
332
|
+
* @throws when another record file cannot be read, before any write
|
|
333
|
+
*/
|
|
334
|
+
async buildMultiFileIndex(filePath, targetRecords, targetDir, entityConfig, entityInfo) {
|
|
335
|
+
const filePattern = entityConfig.pull?.filePattern || entityConfig.filePattern || '*.json';
|
|
336
|
+
const otherFiles = (await FindExistingRecordFiles(targetDir, filePattern, entityConfig.ignoreDirectories ?? []))
|
|
337
|
+
.filter((other) => path.resolve(other) !== path.resolve(filePath));
|
|
338
|
+
const otherEntries = await this.loadExistingRecords(otherFiles);
|
|
339
|
+
const targetEntries = targetRecords.map((existing) => ({ Record: existing, FilePath: filePath, InArray: true }));
|
|
340
|
+
return ExistingRecordIndex.Build([...targetEntries, ...otherEntries], entityInfo);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Records in a multi-file target: none when the file does not exist or holds only whitespace, one for
|
|
344
|
+
* a file that holds an object.
|
|
345
|
+
* @throws when the file is not valid JSON, with the parse error as the cause
|
|
346
|
+
*/
|
|
347
|
+
async readMultiFile(filePath) {
|
|
348
|
+
if (!(await fs.pathExists(filePath))) {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
let content;
|
|
352
|
+
try {
|
|
353
|
+
const text = await fs.readFile(filePath, 'utf8');
|
|
354
|
+
if (text.trim() === '') {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
content = JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
358
|
+
}
|
|
359
|
+
catch (cause) {
|
|
360
|
+
throw new Error(`Cannot read ${filePath}. Pull does not overwrite a file it cannot read.`, { cause });
|
|
361
|
+
}
|
|
362
|
+
return Array.isArray(content) ? content : [content];
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The entries of a multi-file target whose rows were soft-deleted since the timestamp. Empty when
|
|
366
|
+
* the entity has no soft-delete column or the query fails. Entries of other record files are left out.
|
|
367
|
+
*/
|
|
368
|
+
async softDeletedMultiFileEntries(options, sinceTimestamp, existingIndex, filePath, entityInfo, callbacks) {
|
|
369
|
+
const entries = new Set();
|
|
370
|
+
const rows = await this.findSoftDeletedRows(options, sinceTimestamp, callbacks);
|
|
371
|
+
for (const row of rows) {
|
|
372
|
+
const key = {};
|
|
373
|
+
for (const pk of entityInfo.PrimaryKeys) {
|
|
374
|
+
key[pk.Name] = row[pk.Name];
|
|
375
|
+
}
|
|
376
|
+
const entry = existingIndex.Find(key);
|
|
377
|
+
if (entry && entry.FilePath === filePath) {
|
|
378
|
+
entries.add(entry.Record);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return entries;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Rows soft-deleted since the timestamp (`__mj_DeletedAt`). Empty when the query fails, for example
|
|
385
|
+
* for an entity with hard deletes; a verbose note says so.
|
|
386
|
+
*/
|
|
387
|
+
async findSoftDeletedRows(options, sinceTimestamp, callbacks) {
|
|
388
|
+
try {
|
|
389
|
+
if (!isValidISOTimestamp(sinceTimestamp)) {
|
|
390
|
+
throw new Error('Invalid --since timestamp format. Expected ISO 8601 (e.g., 2026-04-07T10:00:00Z)');
|
|
391
|
+
}
|
|
392
|
+
const deletedFilter = `[__mj_DeletedAt] > '${sinceTimestamp}'`;
|
|
393
|
+
const fullFilter = options.filter ? `(${options.filter}) AND ${deletedFilter}` : deletedFilter;
|
|
394
|
+
const result = await new RunView().RunView({
|
|
395
|
+
EntityName: options.entity,
|
|
396
|
+
ExtraFilter: fullFilter,
|
|
397
|
+
ResultType: 'simple',
|
|
398
|
+
}, this.contextUser);
|
|
399
|
+
return result.Success ? result.Results : [];
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// The entity may not support soft deletes (__mj_DeletedAt column may not exist)
|
|
403
|
+
if (options.verbose) {
|
|
404
|
+
callbacks?.onLog?.(` Note: could not check for deleted records (entity may use hard deletes)`);
|
|
405
|
+
}
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** The existing entries with each matched entry replaced in place, then the pulled records that matched none. */
|
|
410
|
+
upsertMultiFileRecords(existingRecords, matched, pulled) {
|
|
411
|
+
const replacements = new Set(matched.values());
|
|
412
|
+
const result = existingRecords.map((existing) => matched.get(existing) ?? existing);
|
|
413
|
+
for (const record of pulled) {
|
|
414
|
+
if (!replacements.has(record)) {
|
|
415
|
+
result.push(record);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return result;
|
|
419
|
+
}
|
|
285
420
|
/**
|
|
286
421
|
* Clean up backup files created during the pull operation
|
|
287
422
|
* Should be called after successful pull operations to remove persistent backup files
|
|
@@ -363,40 +498,28 @@ export class PullService {
|
|
|
363
498
|
* (which filters by __mj_UpdatedAt) would never notice deletions.
|
|
364
499
|
*/
|
|
365
500
|
async removeDeletedRecordFiles(options, sinceTimestamp, targetDir, callbacks) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const id = record[pkField];
|
|
389
|
-
if (!id)
|
|
390
|
-
continue;
|
|
391
|
-
// Try common file naming patterns.
|
|
392
|
-
// NOTE: This is a best-effort heuristic. If the entity uses a custom
|
|
393
|
-
// file naming scheme (e.g., via newFileName config) these patterns may
|
|
394
|
-
// miss. A single-file-per-entity layout is also not covered here.
|
|
395
|
-
const candidates = [
|
|
396
|
-
path.join(targetDir, `${id}.json`),
|
|
397
|
-
path.join(targetDir, `${record.Name ?? id}.json`),
|
|
398
|
-
path.join(targetDir, `${(record.Name ?? '').replace(/[^a-zA-Z0-9-_ ]/g, '')}.json`),
|
|
399
|
-
];
|
|
501
|
+
const rows = await this.findSoftDeletedRows(options, sinceTimestamp, callbacks);
|
|
502
|
+
if (rows.length === 0) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const entityInfo = this.syncEngine.getEntityInfo(options.entity);
|
|
506
|
+
const pkField = entityInfo?.PrimaryKeys?.[0]?.Name ?? 'ID';
|
|
507
|
+
let removed = 0;
|
|
508
|
+
for (const record of rows) {
|
|
509
|
+
const id = record[pkField];
|
|
510
|
+
if (!id)
|
|
511
|
+
continue;
|
|
512
|
+
const name = typeof record.Name === 'string' ? record.Name : undefined;
|
|
513
|
+
// Try common file naming patterns.
|
|
514
|
+
// NOTE: This is a best-effort heuristic. If the entity uses a custom
|
|
515
|
+
// file naming scheme (e.g., via newFileName config) these patterns may
|
|
516
|
+
// miss. A single-file-per-entity layout is also not covered here.
|
|
517
|
+
const candidates = [
|
|
518
|
+
path.join(targetDir, `${id}.json`),
|
|
519
|
+
path.join(targetDir, `${name ?? id}.json`),
|
|
520
|
+
path.join(targetDir, `${(name ?? '').replace(/[^a-zA-Z0-9-_ ]/g, '')}.json`),
|
|
521
|
+
];
|
|
522
|
+
try {
|
|
400
523
|
for (const filePath of candidates) {
|
|
401
524
|
if (await fs.pathExists(filePath)) {
|
|
402
525
|
await fs.remove(filePath);
|
|
@@ -408,26 +531,26 @@ export class PullService {
|
|
|
408
531
|
}
|
|
409
532
|
}
|
|
410
533
|
}
|
|
411
|
-
|
|
412
|
-
|
|
534
|
+
catch {
|
|
535
|
+
// Non-fatal: the file stays and the pull goes on
|
|
536
|
+
if (options.verbose) {
|
|
537
|
+
callbacks?.onLog?.(` Note: could not remove the file of a deleted record`);
|
|
538
|
+
}
|
|
413
539
|
}
|
|
414
540
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
// (__mj_DeletedAt column may not exist), in which case the query fails.
|
|
418
|
-
if (options.verbose) {
|
|
419
|
-
callbacks?.onLog?.(` Note: could not check for deleted records (entity may use hard deletes)`);
|
|
420
|
-
}
|
|
541
|
+
if (removed > 0) {
|
|
542
|
+
callbacks?.onSuccess?.(`Removed ${removed} files for soft-deleted records`);
|
|
421
543
|
}
|
|
422
544
|
}
|
|
423
545
|
/**
|
|
424
546
|
* Rollback file changes by restoring from backup files
|
|
425
547
|
* Called when pull operation fails after files have been modified
|
|
548
|
+
* @returns the number of files restored
|
|
426
549
|
*/
|
|
427
550
|
async rollbackFileChanges(callbacks) {
|
|
428
551
|
if (this.createdBackupFiles.length === 0) {
|
|
429
552
|
callbacks?.onLog?.('No backup files found - no rollback needed');
|
|
430
|
-
return;
|
|
553
|
+
return 0;
|
|
431
554
|
}
|
|
432
555
|
callbacks?.onProgress?.(`Rolling back ${this.createdBackupFiles.length} file changes...`);
|
|
433
556
|
const errors = [];
|
|
@@ -452,6 +575,7 @@ export class PullService {
|
|
|
452
575
|
throw new Error(`Rollback completed with ${errors.length} errors (${restoredCount} files restored):\n${errors.join('\n')}`);
|
|
453
576
|
}
|
|
454
577
|
callbacks?.onSuccess?.(`Successfully rolled back ${restoredCount} file changes`);
|
|
578
|
+
return restoredCount;
|
|
455
579
|
}
|
|
456
580
|
async processIndividualRecords(records, options, targetDir, entityConfig, entityInfo, callbacks, batchedRelatedData) {
|
|
457
581
|
let processed = 0;
|
|
@@ -465,28 +589,31 @@ export class PullService {
|
|
|
465
589
|
callbacks?.onLog?.(`Found ${existingFiles.length} existing files matching pattern '${filePattern}'`);
|
|
466
590
|
existingFiles.forEach(f => callbacks?.onLog?.(` - ${path.basename(f)}`));
|
|
467
591
|
}
|
|
468
|
-
// Load existing records and
|
|
469
|
-
const
|
|
592
|
+
// Load existing records and index them by the resolved primary key
|
|
593
|
+
const existingEntries = await this.loadExistingRecords(existingFiles);
|
|
594
|
+
const existingIndex = ExistingRecordIndex.Build(existingEntries, entityInfo);
|
|
595
|
+
existingIndex.ThrowIfUnsetKeyVariables(options.entity);
|
|
470
596
|
if (options.verbose) {
|
|
471
|
-
callbacks?.onLog?.(`Loaded ${
|
|
597
|
+
callbacks?.onLog?.(`Loaded ${existingEntries.length} existing records from files`);
|
|
598
|
+
if (existingIndex.DuplicateCount > 0) {
|
|
599
|
+
callbacks?.onLog?.(`${existingIndex.DuplicateCount} existing records repeat a primary key; the first one in file order is used`);
|
|
600
|
+
}
|
|
472
601
|
}
|
|
473
602
|
// Separate records into new and existing
|
|
474
603
|
const newRecords = [];
|
|
475
604
|
const existingRecordsToUpdate = [];
|
|
476
605
|
for (const record of records) {
|
|
477
606
|
const primaryKey = extractPrimaryKeyValues(record, entityInfo);
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const existingFileInfo = existingRecordsMap.get(lookupKey);
|
|
481
|
-
if (existingFileInfo) {
|
|
607
|
+
const existing = existingIndex.Find(primaryKey);
|
|
608
|
+
if (existing) {
|
|
482
609
|
// Record exists locally
|
|
483
610
|
if (entityConfig.pull?.updateExistingRecords !== false) {
|
|
484
|
-
existingRecordsToUpdate.push({ record, primaryKey,
|
|
611
|
+
existingRecordsToUpdate.push({ record, primaryKey, existing });
|
|
485
612
|
}
|
|
486
613
|
else {
|
|
487
614
|
skipped++;
|
|
488
615
|
if (options.verbose) {
|
|
489
|
-
callbacks?.onLog?.(`Skipping existing record: ${
|
|
616
|
+
callbacks?.onLog?.(`Skipping existing record: ${ExistingRecordIndex.FileLookupOf(existing.Record)}`);
|
|
490
617
|
}
|
|
491
618
|
}
|
|
492
619
|
}
|
|
@@ -498,39 +625,40 @@ export class PullService {
|
|
|
498
625
|
else {
|
|
499
626
|
skipped++;
|
|
500
627
|
if (options.verbose) {
|
|
501
|
-
callbacks?.onLog?.(`Skipping new record (createNewFileIfNotFound=false): ${
|
|
628
|
+
callbacks?.onLog?.(`Skipping new record (createNewFileIfNotFound=false): ${createPrimaryKeyLookup(primaryKey)}`);
|
|
502
629
|
}
|
|
503
630
|
}
|
|
504
631
|
}
|
|
505
632
|
}
|
|
506
633
|
const errors = [];
|
|
634
|
+
const unsetKeyErrors = [];
|
|
507
635
|
// Process existing records updates in parallel
|
|
508
636
|
if (existingRecordsToUpdate.length > 0) {
|
|
509
637
|
callbacks?.onProgress?.(`Updating existing records (parallel processing)`);
|
|
510
|
-
const
|
|
638
|
+
const mergeStrategy = entityConfig.pull?.mergeStrategy || 'merge';
|
|
639
|
+
const updatePromises = existingRecordsToUpdate.map(async ({ record, primaryKey, existing }, index) => {
|
|
640
|
+
const filePath = existing.FilePath ?? '';
|
|
641
|
+
if (mergeStrategy === 'skip') {
|
|
642
|
+
if (options.verbose) {
|
|
643
|
+
callbacks?.onLog?.(`Skipped (mergeStrategy=skip): ${filePath}`);
|
|
644
|
+
}
|
|
645
|
+
return { success: true, skipped: true, index };
|
|
646
|
+
}
|
|
511
647
|
try {
|
|
512
648
|
// Backups are taken for every file the batch will rewrite, just before it flushes.
|
|
513
|
-
|
|
514
|
-
const existingData = await fs.readJson(filePath);
|
|
515
|
-
// Find the specific existing record that matches this primary key
|
|
516
|
-
let existingRecordData;
|
|
517
|
-
if (Array.isArray(existingData)) {
|
|
518
|
-
// Find the matching record in the array
|
|
519
|
-
const matchingRecord = existingData.find(r => createPrimaryKeyLookup(r.primaryKey) === createPrimaryKeyLookup(primaryKey));
|
|
520
|
-
existingRecordData = matchingRecord || existingData[0]; // Fallback to first if not found
|
|
521
|
-
}
|
|
522
|
-
else {
|
|
523
|
-
existingRecordData = existingData;
|
|
524
|
-
}
|
|
649
|
+
const existingRecordData = existing.Record;
|
|
525
650
|
// Process the new record data (isNewRecord = false for updates)
|
|
526
651
|
const newRecordData = await this.recordProcessor.processRecord(record, primaryKey, targetDir, entityConfig, options.verbose, false, existingRecordData, 0, new Set(), undefined, batchedRelatedData);
|
|
527
652
|
// Apply merge strategy
|
|
528
|
-
const mergedData = await this.mergeRecords(existingRecordData, newRecordData,
|
|
653
|
+
const mergedData = await this.mergeRecords(existingRecordData, newRecordData, mergeStrategy, entityConfig.pull?.preserveFields || []);
|
|
654
|
+
// A field left out because it holds an env value does not come back from the existing file
|
|
655
|
+
for (const fieldName of this.recordProcessor.EnvOmittedFieldsOf(newRecordData)) {
|
|
656
|
+
delete mergedData.fields[fieldName];
|
|
657
|
+
}
|
|
529
658
|
// Queue updated data for batched write
|
|
530
|
-
if (
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
this.fileWriteBatch.queueArrayUpdate(filePath, mergedData, primaryKeyLookup);
|
|
659
|
+
if (existing.InArray) {
|
|
660
|
+
// The batch finds the entry by the key the file holds
|
|
661
|
+
this.fileWriteBatch.queueArrayUpdate(filePath, mergedData, ExistingRecordIndex.FileLookupOf(existingRecordData));
|
|
534
662
|
}
|
|
535
663
|
else {
|
|
536
664
|
// Queue single record update
|
|
@@ -539,17 +667,21 @@ export class PullService {
|
|
|
539
667
|
if (options.verbose) {
|
|
540
668
|
callbacks?.onLog?.(`Updated: ${filePath}`);
|
|
541
669
|
}
|
|
542
|
-
return { success: true, index };
|
|
670
|
+
return { success: true, skipped: false, index };
|
|
543
671
|
}
|
|
544
672
|
catch (error) {
|
|
673
|
+
if (this.keepUnsetKeyError(error, index, filePath, options.entity, unsetKeyErrors)) {
|
|
674
|
+
return { success: false, skipped: false, index };
|
|
675
|
+
}
|
|
545
676
|
const errorMessage = `Failed to update record ${index + 1}: ${error.message || error}`;
|
|
546
677
|
errors.push(errorMessage);
|
|
547
678
|
callbacks?.onWarn?.(errorMessage);
|
|
548
|
-
return { success: false, index };
|
|
679
|
+
return { success: false, skipped: false, index };
|
|
549
680
|
}
|
|
550
681
|
});
|
|
551
682
|
const updateResults = await Promise.all(updatePromises);
|
|
552
|
-
updated = updateResults.filter(r => r.success).length;
|
|
683
|
+
updated = updateResults.filter(r => r.success && !r.skipped).length;
|
|
684
|
+
skipped += updateResults.filter(r => r.skipped).length;
|
|
553
685
|
processed += updated;
|
|
554
686
|
if (options.verbose) {
|
|
555
687
|
callbacks?.onSuccess?.(`Completed ${updated}/${existingRecordsToUpdate.length} record updates`);
|
|
@@ -576,6 +708,9 @@ export class PullService {
|
|
|
576
708
|
return { success: true, index };
|
|
577
709
|
}
|
|
578
710
|
catch (error) {
|
|
711
|
+
if (this.keepUnsetKeyError(error, existingRecordsToUpdate.length + index, filePath, options.entity, unsetKeyErrors)) {
|
|
712
|
+
return { success: false, index };
|
|
713
|
+
}
|
|
579
714
|
const errorMessage = `Failed to process new record ${index + 1}: ${error.message || error}`;
|
|
580
715
|
errors.push(errorMessage);
|
|
581
716
|
callbacks?.onWarn?.(errorMessage);
|
|
@@ -597,6 +732,9 @@ export class PullService {
|
|
|
597
732
|
return { success: true, index };
|
|
598
733
|
}
|
|
599
734
|
catch (error) {
|
|
735
|
+
if (this.keepUnsetKeyError(error, existingRecordsToUpdate.length + index, undefined, options.entity, unsetKeyErrors)) {
|
|
736
|
+
return { success: false, index };
|
|
737
|
+
}
|
|
600
738
|
const errorMessage = `Failed to process new record ${index + 1}: ${error.message || error}`;
|
|
601
739
|
errors.push(errorMessage);
|
|
602
740
|
callbacks?.onWarn?.(errorMessage);
|
|
@@ -611,12 +749,30 @@ export class PullService {
|
|
|
611
749
|
}
|
|
612
750
|
}
|
|
613
751
|
}
|
|
752
|
+
this.throwFirstUnsetKeyError(unsetKeyErrors);
|
|
614
753
|
// If there were errors during parallel processing, throw them
|
|
615
754
|
if (errors.length > 0) {
|
|
616
755
|
throw new Error(`Parallel processing completed with ${errors.length} errors:\n${errors.join('\n')}`);
|
|
617
756
|
}
|
|
618
757
|
return { processed, updated, created, skipped };
|
|
619
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Keeps an unset key variable error, with the record file and the pulled entity in its message,
|
|
761
|
+
* so the pull rethrows it as is. Returns false for any other error.
|
|
762
|
+
*/
|
|
763
|
+
keepUnsetKeyError(error, index, filePath, pulledEntityName, into) {
|
|
764
|
+
if (!(error instanceof UnsetPrimaryKeyVariableError)) {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
into.push({ Index: index, Error: error.InRecordFile(filePath, pulledEntityName) });
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
/** @throws the kept UnsetPrimaryKeyVariableError of the lowest record index, when there is one */
|
|
771
|
+
throwFirstUnsetKeyError(errors) {
|
|
772
|
+
if (errors.length > 0) {
|
|
773
|
+
throw errors.reduce((first, next) => (next.Index < first.Index ? next : first)).Error;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
620
776
|
async processRecord(record, primaryKey, targetDir, entityConfig, verbose, batchedRelatedData) {
|
|
621
777
|
const recordData = await this.recordProcessor.processRecord(record, primaryKey, targetDir, entityConfig, verbose, true, undefined, 0, new Set(), undefined, batchedRelatedData);
|
|
622
778
|
// Determine file path
|
|
@@ -673,24 +829,28 @@ export class PullService {
|
|
|
673
829
|
}
|
|
674
830
|
return batchedRelatedData.size > 0 ? batchedRelatedData : undefined;
|
|
675
831
|
}
|
|
676
|
-
|
|
677
|
-
|
|
832
|
+
/**
|
|
833
|
+
* Reads every record in the files.
|
|
834
|
+
* @throws when a file cannot be read, before any write: a record in it could otherwise be written again as new
|
|
835
|
+
*/
|
|
836
|
+
async loadExistingRecords(files) {
|
|
837
|
+
const entries = [];
|
|
678
838
|
for (const filePath of files) {
|
|
839
|
+
let fileData;
|
|
679
840
|
try {
|
|
680
|
-
|
|
681
|
-
const records = Array.isArray(fileData) ? fileData : [fileData];
|
|
682
|
-
for (const record of records) {
|
|
683
|
-
if (record.primaryKey) {
|
|
684
|
-
const lookupKey = createPrimaryKeyLookup(record.primaryKey);
|
|
685
|
-
recordsMap.set(lookupKey, { filePath, recordData: record });
|
|
686
|
-
}
|
|
687
|
-
}
|
|
841
|
+
fileData = await fs.readJson(filePath);
|
|
688
842
|
}
|
|
689
|
-
catch (
|
|
690
|
-
|
|
843
|
+
catch (cause) {
|
|
844
|
+
throw new Error(`Cannot read ${filePath}. Pull does not write new records while a record file in the folder cannot be read.`, { cause });
|
|
845
|
+
}
|
|
846
|
+
const records = Array.isArray(fileData) ? fileData : [fileData];
|
|
847
|
+
for (const record of records) {
|
|
848
|
+
if (record?.primaryKey) {
|
|
849
|
+
entries.push({ Record: record, FilePath: filePath, InArray: Array.isArray(fileData) });
|
|
850
|
+
}
|
|
691
851
|
}
|
|
692
852
|
}
|
|
693
|
-
return
|
|
853
|
+
return entries;
|
|
694
854
|
}
|
|
695
855
|
async findEntityDirectories(entityName) {
|
|
696
856
|
const dirs = [];
|