@memberjunction/metadata-sync 6.2.0-edge.3 → 6.2.0-edge.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -53
- package/dist/config.d.ts +5 -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 +33 -2
- 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/entity-subclass-guard.d.ts +8 -7
- package/dist/lib/entity-subclass-guard.d.ts.map +1 -1
- package/dist/lib/entity-subclass-guard.js +20 -8
- package/dist/lib/entity-subclass-guard.js.map +1 -1
- 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 +297 -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/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 +2 -0
- package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
- 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/sync-engine.d.ts +52 -4
- package/dist/lib/sync-engine.d.ts.map +1 -1
- package/dist/lib/sync-engine.js +233 -61
- 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 +55 -23
- package/dist/plugins/index.js.map +1 -1
- package/dist/services/FormattingService.d.ts +14 -7
- package/dist/services/FormattingService.d.ts.map +1 -1
- package/dist/services/FormattingService.js +22 -9
- package/dist/services/FormattingService.js.map +1 -1
- package/dist/services/PullService.d.ts +53 -1
- package/dist/services/PullService.d.ts.map +1 -1
- package/dist/services/PullService.js +323 -120
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.d.ts +59 -0
- package/dist/services/PushService.d.ts.map +1 -1
- package/dist/services/PushService.js +448 -173
- 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 +26 -0
- package/dist/services/WatchService.d.ts.map +1 -1
- package/dist/services/WatchService.js +210 -75
- 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
|
@@ -7,6 +7,24 @@ import { configManager } from '../lib/config-manager.js';
|
|
|
7
7
|
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
|
+
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';
|
|
13
|
+
/**
|
|
14
|
+
* Resolves `pull.backupDirectory` against the directory of the file being backed up (default
|
|
15
|
+
* `.backups`). An absolute path is taken as relative to that directory, as `path.join` always has.
|
|
16
|
+
*
|
|
17
|
+
* @throws when the result is outside that directory (`../..`): pull must not write copies of
|
|
18
|
+
* metadata files elsewhere on disk.
|
|
19
|
+
*/
|
|
20
|
+
function resolveBackupDirectory(fileDir, backupDirName) {
|
|
21
|
+
const backupDir = path.join(fileDir, backupDirName || '.backups');
|
|
22
|
+
const relative = path.relative(fileDir, backupDir);
|
|
23
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
24
|
+
throw new Error(`pull.backupDirectory '${backupDirName}' points outside ${fileDir}. It must be a directory inside the folder of the files it backs up.`);
|
|
25
|
+
}
|
|
26
|
+
return backupDir;
|
|
27
|
+
}
|
|
10
28
|
/** Validates that a string is a well-formed ISO 8601 timestamp. */
|
|
11
29
|
function isValidISOTimestamp(value) {
|
|
12
30
|
return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(value);
|
|
@@ -15,6 +33,8 @@ export class PullService {
|
|
|
15
33
|
constructor(syncEngine, contextUser, stateManager) {
|
|
16
34
|
this.createdBackupFiles = [];
|
|
17
35
|
this.createdBackupDirs = new Set();
|
|
36
|
+
/** Backup path → the file it copies, so a rollback restores to the right place for any backupDirectory. */
|
|
37
|
+
this.backupSources = new Map();
|
|
18
38
|
this.syncEngine = syncEngine;
|
|
19
39
|
this.contextUser = contextUser;
|
|
20
40
|
this.fileWriteBatch = new FileWriteBatch();
|
|
@@ -38,6 +58,8 @@ export class PullService {
|
|
|
38
58
|
this.fileWriteBatch.clear();
|
|
39
59
|
this.createdBackupFiles = [];
|
|
40
60
|
this.createdBackupDirs.clear();
|
|
61
|
+
this.backupSources.clear();
|
|
62
|
+
this.recordProcessor.WarningSink = (message) => callbacks?.onWarn?.(`⚠️ ${message}`);
|
|
41
63
|
let targetDir;
|
|
42
64
|
let entityConfig;
|
|
43
65
|
// Check if we should use a specific target directory
|
|
@@ -75,6 +97,7 @@ export class PullService {
|
|
|
75
97
|
throw new Error(`Invalid entity configuration in ${targetDir}`);
|
|
76
98
|
}
|
|
77
99
|
}
|
|
100
|
+
entityConfig = ApplyPullOverrides(entityConfig, this.pullOverridesFor(options));
|
|
78
101
|
// Show configuration notice only if relevant and in verbose mode
|
|
79
102
|
if (options.verbose && entityConfig?.pull?.appendRecordsToExistingFile && entityConfig?.pull?.newFileName) {
|
|
80
103
|
const targetFile = path.join(targetDir, entityConfig.pull.newFileName.endsWith('.json')
|
|
@@ -84,6 +107,12 @@ export class PullService {
|
|
|
84
107
|
callbacks?.onLog?.(`\n📝 Configuration: New records will be appended to existing file '${path.basename(targetFile)}'`);
|
|
85
108
|
}
|
|
86
109
|
}
|
|
110
|
+
// Records load through the ClassFactory; without the entity's own subclass they arrive as a
|
|
111
|
+
// generic BaseEntity. Pull stays correct (keys are read through Get()), but say so once.
|
|
112
|
+
const subclassWarning = DescribeMissingEntitySubclass(options.entity, { Operation: 'pull', DryRun: options.dryRun });
|
|
113
|
+
if (subclassWarning) {
|
|
114
|
+
callbacks?.onWarn?.(`⚠️ ${subclassWarning}`);
|
|
115
|
+
}
|
|
87
116
|
// Pull records
|
|
88
117
|
callbacks?.onProgress?.(`Pulling ${options.entity} records`);
|
|
89
118
|
const rv = new RunView();
|
|
@@ -132,9 +161,14 @@ export class PullService {
|
|
|
132
161
|
// Process records with error handling and rollback
|
|
133
162
|
let pullResult;
|
|
134
163
|
try {
|
|
135
|
-
pullResult = await this.processRecords(result.Results, options, targetDir, entityConfig, callbacks);
|
|
164
|
+
pullResult = await this.processRecords(result.Results, options, targetDir, entityConfig, callbacks, sinceTimestamp);
|
|
136
165
|
// Write all batched file changes at once
|
|
137
166
|
if (!options.dryRun) {
|
|
167
|
+
// Back up every existing file this pull is about to rewrite — updated and appended-to
|
|
168
|
+
// alike — before anything is written, so a failed pull can restore all of them.
|
|
169
|
+
if (entityConfig.pull?.backupBeforeUpdate) {
|
|
170
|
+
await this.backupPendingFiles(entityConfig.pull.backupDirectory, options.verbose, callbacks);
|
|
171
|
+
}
|
|
138
172
|
const filesWritten = await this.fileWriteBatch.flush();
|
|
139
173
|
if (options.verbose && filesWritten > 0) {
|
|
140
174
|
callbacks?.onSuccess?.(`Wrote ${filesWritten} files with consistent property ordering`);
|
|
@@ -142,7 +176,7 @@ export class PullService {
|
|
|
142
176
|
}
|
|
143
177
|
// Operation succeeded - clean up backup files
|
|
144
178
|
if (!options.dryRun) {
|
|
145
|
-
await this.
|
|
179
|
+
await this.cleanupBackupFiles();
|
|
146
180
|
}
|
|
147
181
|
}
|
|
148
182
|
catch (error) {
|
|
@@ -150,8 +184,10 @@ export class PullService {
|
|
|
150
184
|
// Attempt to rollback file changes if not in dry run mode
|
|
151
185
|
if (!options.dryRun) {
|
|
152
186
|
try {
|
|
153
|
-
await this.rollbackFileChanges(callbacks);
|
|
154
|
-
|
|
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
|
+
}
|
|
155
191
|
}
|
|
156
192
|
catch (rollbackError) {
|
|
157
193
|
callbacks?.onError?.(`Rollback failed: ${rollbackError.message || rollbackError}`);
|
|
@@ -162,7 +198,8 @@ export class PullService {
|
|
|
162
198
|
// Incremental pull: detect soft-deleted records and remove their local files.
|
|
163
199
|
// Records deleted via __mj_DeletedAt won't appear in the UpdatedAt query,
|
|
164
200
|
// so we need a separate check.
|
|
165
|
-
|
|
201
|
+
// A multi-file pull removes soft-deleted entries from the file itself
|
|
202
|
+
if (sinceTimestamp && !options.dryRun && !options.multiFile) {
|
|
166
203
|
await this.removeDeletedRecordFiles(options, sinceTimestamp, targetDir, callbacks);
|
|
167
204
|
}
|
|
168
205
|
// Persist incremental state after a successful pull
|
|
@@ -175,11 +212,21 @@ export class PullService {
|
|
|
175
212
|
targetDir
|
|
176
213
|
};
|
|
177
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
|
+
}
|
|
178
225
|
/** @deprecated Use {@link Pull}. */
|
|
179
226
|
async pull(options, callbacks) {
|
|
180
227
|
return this.Pull(options, callbacks);
|
|
181
228
|
}
|
|
182
|
-
async processRecords(records, options, targetDir, entityConfig, callbacks) {
|
|
229
|
+
async processRecords(records, options, targetDir, entityConfig, callbacks, sinceTimestamp) {
|
|
183
230
|
const entityInfo = this.syncEngine.getEntityInfo(options.entity);
|
|
184
231
|
if (!entityInfo) {
|
|
185
232
|
throw new Error(`Entity information not found for: ${options.entity}`);
|
|
@@ -193,38 +240,64 @@ export class PullService {
|
|
|
193
240
|
const batchedRelatedData = await this.prefetchRelatedEntities(records, entityConfig, entityInfo, options.verbose, callbacks);
|
|
194
241
|
// If multi-file flag is set, collect all records
|
|
195
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);
|
|
196
248
|
const allRecords = [];
|
|
249
|
+
const matched = new Map();
|
|
197
250
|
const errors = [];
|
|
251
|
+
const unsetKeyErrors = [];
|
|
198
252
|
// Process records in parallel for multi-file mode
|
|
199
253
|
const recordPromises = records.map(async (record, index) => {
|
|
200
254
|
try {
|
|
201
255
|
const primaryKey = extractPrimaryKeyValues(record, entityInfo);
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
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 };
|
|
205
262
|
}
|
|
206
263
|
catch (error) {
|
|
207
|
-
|
|
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)}`;
|
|
208
268
|
errors.push(errorMessage);
|
|
209
269
|
callbacks?.onWarn?.(errorMessage);
|
|
210
|
-
return { success: false, recordData: null, index };
|
|
270
|
+
return { success: false, recordData: null, existing: undefined, index };
|
|
211
271
|
}
|
|
212
272
|
});
|
|
213
273
|
const recordResults = await Promise.all(recordPromises);
|
|
274
|
+
this.throwFirstUnsetKeyError(unsetKeyErrors);
|
|
214
275
|
// Collect successful records
|
|
215
276
|
for (const result of recordResults) {
|
|
216
277
|
if (result.success && result.recordData) {
|
|
217
278
|
allRecords.push(result.recordData);
|
|
279
|
+
if (result.existing) {
|
|
280
|
+
matched.set(result.existing, result.recordData);
|
|
281
|
+
}
|
|
218
282
|
processed++;
|
|
219
283
|
}
|
|
220
284
|
}
|
|
221
285
|
if (options.verbose) {
|
|
222
286
|
callbacks?.onProgress?.(`Processed ${processed}/${records.length} records in parallel`);
|
|
223
287
|
}
|
|
224
|
-
//
|
|
225
|
-
if (
|
|
226
|
-
const
|
|
227
|
-
|
|
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) {
|
|
228
301
|
this.fileWriteBatch.queueWrite(filePath, allRecords);
|
|
229
302
|
callbacks?.onSuccess?.(`Queued ${processed} records for ${path.basename(filePath)}`);
|
|
230
303
|
}
|
|
@@ -252,6 +325,98 @@ export class PullService {
|
|
|
252
325
|
}
|
|
253
326
|
return { processed, created, updated, skipped };
|
|
254
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
|
+
}
|
|
255
420
|
/**
|
|
256
421
|
* Clean up backup files created during the pull operation
|
|
257
422
|
* Should be called after successful pull operations to remove persistent backup files
|
|
@@ -333,40 +498,28 @@ export class PullService {
|
|
|
333
498
|
* (which filters by __mj_UpdatedAt) would never notice deletions.
|
|
334
499
|
*/
|
|
335
500
|
async removeDeletedRecordFiles(options, sinceTimestamp, targetDir, callbacks) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const id = record[pkField];
|
|
359
|
-
if (!id)
|
|
360
|
-
continue;
|
|
361
|
-
// Try common file naming patterns.
|
|
362
|
-
// NOTE: This is a best-effort heuristic. If the entity uses a custom
|
|
363
|
-
// file naming scheme (e.g., via newFileName config) these patterns may
|
|
364
|
-
// miss. A single-file-per-entity layout is also not covered here.
|
|
365
|
-
const candidates = [
|
|
366
|
-
path.join(targetDir, `${id}.json`),
|
|
367
|
-
path.join(targetDir, `${record.Name ?? id}.json`),
|
|
368
|
-
path.join(targetDir, `${(record.Name ?? '').replace(/[^a-zA-Z0-9-_ ]/g, '')}.json`),
|
|
369
|
-
];
|
|
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 {
|
|
370
523
|
for (const filePath of candidates) {
|
|
371
524
|
if (await fs.pathExists(filePath)) {
|
|
372
525
|
await fs.remove(filePath);
|
|
@@ -378,38 +531,37 @@ export class PullService {
|
|
|
378
531
|
}
|
|
379
532
|
}
|
|
380
533
|
}
|
|
381
|
-
|
|
382
|
-
|
|
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
|
+
}
|
|
383
539
|
}
|
|
384
540
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
// (__mj_DeletedAt column may not exist), in which case the query fails.
|
|
388
|
-
if (options.verbose) {
|
|
389
|
-
callbacks?.onLog?.(` Note: could not check for deleted records (entity may use hard deletes)`);
|
|
390
|
-
}
|
|
541
|
+
if (removed > 0) {
|
|
542
|
+
callbacks?.onSuccess?.(`Removed ${removed} files for soft-deleted records`);
|
|
391
543
|
}
|
|
392
544
|
}
|
|
393
545
|
/**
|
|
394
546
|
* Rollback file changes by restoring from backup files
|
|
395
547
|
* Called when pull operation fails after files have been modified
|
|
548
|
+
* @returns the number of files restored
|
|
396
549
|
*/
|
|
397
550
|
async rollbackFileChanges(callbacks) {
|
|
398
551
|
if (this.createdBackupFiles.length === 0) {
|
|
399
552
|
callbacks?.onLog?.('No backup files found - no rollback needed');
|
|
400
|
-
return;
|
|
553
|
+
return 0;
|
|
401
554
|
}
|
|
402
555
|
callbacks?.onProgress?.(`Rolling back ${this.createdBackupFiles.length} file changes...`);
|
|
403
556
|
const errors = [];
|
|
404
557
|
let restoredCount = 0;
|
|
405
558
|
for (const backupPath of this.createdBackupFiles) {
|
|
406
559
|
try {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const originalFilePath = path.join(path.dirname(backupDir), originalFileName);
|
|
560
|
+
const originalFilePath = this.backupSources.get(backupPath);
|
|
561
|
+
if (!originalFilePath) {
|
|
562
|
+
errors.push(`No source recorded for backup ${backupPath}`);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
413
565
|
if (await fs.pathExists(backupPath)) {
|
|
414
566
|
await fs.copy(backupPath, originalFilePath);
|
|
415
567
|
restoredCount++;
|
|
@@ -423,6 +575,7 @@ export class PullService {
|
|
|
423
575
|
throw new Error(`Rollback completed with ${errors.length} errors (${restoredCount} files restored):\n${errors.join('\n')}`);
|
|
424
576
|
}
|
|
425
577
|
callbacks?.onSuccess?.(`Successfully rolled back ${restoredCount} file changes`);
|
|
578
|
+
return restoredCount;
|
|
426
579
|
}
|
|
427
580
|
async processIndividualRecords(records, options, targetDir, entityConfig, entityInfo, callbacks, batchedRelatedData) {
|
|
428
581
|
let processed = 0;
|
|
@@ -436,28 +589,31 @@ export class PullService {
|
|
|
436
589
|
callbacks?.onLog?.(`Found ${existingFiles.length} existing files matching pattern '${filePattern}'`);
|
|
437
590
|
existingFiles.forEach(f => callbacks?.onLog?.(` - ${path.basename(f)}`));
|
|
438
591
|
}
|
|
439
|
-
// Load existing records and
|
|
440
|
-
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);
|
|
441
596
|
if (options.verbose) {
|
|
442
|
-
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
|
+
}
|
|
443
601
|
}
|
|
444
602
|
// Separate records into new and existing
|
|
445
603
|
const newRecords = [];
|
|
446
604
|
const existingRecordsToUpdate = [];
|
|
447
605
|
for (const record of records) {
|
|
448
606
|
const primaryKey = extractPrimaryKeyValues(record, entityInfo);
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const existingFileInfo = existingRecordsMap.get(lookupKey);
|
|
452
|
-
if (existingFileInfo) {
|
|
607
|
+
const existing = existingIndex.Find(primaryKey);
|
|
608
|
+
if (existing) {
|
|
453
609
|
// Record exists locally
|
|
454
610
|
if (entityConfig.pull?.updateExistingRecords !== false) {
|
|
455
|
-
existingRecordsToUpdate.push({ record, primaryKey,
|
|
611
|
+
existingRecordsToUpdate.push({ record, primaryKey, existing });
|
|
456
612
|
}
|
|
457
613
|
else {
|
|
458
614
|
skipped++;
|
|
459
615
|
if (options.verbose) {
|
|
460
|
-
callbacks?.onLog?.(`Skipping existing record: ${
|
|
616
|
+
callbacks?.onLog?.(`Skipping existing record: ${ExistingRecordIndex.FileLookupOf(existing.Record)}`);
|
|
461
617
|
}
|
|
462
618
|
}
|
|
463
619
|
}
|
|
@@ -469,45 +625,40 @@ export class PullService {
|
|
|
469
625
|
else {
|
|
470
626
|
skipped++;
|
|
471
627
|
if (options.verbose) {
|
|
472
|
-
callbacks?.onLog?.(`Skipping new record (createNewFileIfNotFound=false): ${
|
|
628
|
+
callbacks?.onLog?.(`Skipping new record (createNewFileIfNotFound=false): ${createPrimaryKeyLookup(primaryKey)}`);
|
|
473
629
|
}
|
|
474
630
|
}
|
|
475
631
|
}
|
|
476
632
|
}
|
|
477
|
-
// Track which files have been backed up to avoid duplicates
|
|
478
|
-
const backedUpFiles = new Set();
|
|
479
633
|
const errors = [];
|
|
634
|
+
const unsetKeyErrors = [];
|
|
480
635
|
// Process existing records updates in parallel
|
|
481
636
|
if (existingRecordsToUpdate.length > 0) {
|
|
482
637
|
callbacks?.onProgress?.(`Updating existing records (parallel processing)`);
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
// Load existing file data
|
|
491
|
-
const existingData = await fs.readJson(filePath);
|
|
492
|
-
// Find the specific existing record that matches this primary key
|
|
493
|
-
let existingRecordData;
|
|
494
|
-
if (Array.isArray(existingData)) {
|
|
495
|
-
// Find the matching record in the array
|
|
496
|
-
const matchingRecord = existingData.find(r => createPrimaryKeyLookup(r.primaryKey) === createPrimaryKeyLookup(primaryKey));
|
|
497
|
-
existingRecordData = matchingRecord || existingData[0]; // Fallback to first if not found
|
|
498
|
-
}
|
|
499
|
-
else {
|
|
500
|
-
existingRecordData = existingData;
|
|
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}`);
|
|
501
644
|
}
|
|
645
|
+
return { success: true, skipped: true, index };
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
// Backups are taken for every file the batch will rewrite, just before it flushes.
|
|
649
|
+
const existingRecordData = existing.Record;
|
|
502
650
|
// Process the new record data (isNewRecord = false for updates)
|
|
503
651
|
const newRecordData = await this.recordProcessor.processRecord(record, primaryKey, targetDir, entityConfig, options.verbose, false, existingRecordData, 0, new Set(), undefined, batchedRelatedData);
|
|
504
652
|
// Apply merge strategy
|
|
505
|
-
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
|
+
}
|
|
506
658
|
// Queue updated data for batched write
|
|
507
|
-
if (
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
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));
|
|
511
662
|
}
|
|
512
663
|
else {
|
|
513
664
|
// Queue single record update
|
|
@@ -516,17 +667,21 @@ export class PullService {
|
|
|
516
667
|
if (options.verbose) {
|
|
517
668
|
callbacks?.onLog?.(`Updated: ${filePath}`);
|
|
518
669
|
}
|
|
519
|
-
return { success: true, index };
|
|
670
|
+
return { success: true, skipped: false, index };
|
|
520
671
|
}
|
|
521
672
|
catch (error) {
|
|
673
|
+
if (this.keepUnsetKeyError(error, index, filePath, options.entity, unsetKeyErrors)) {
|
|
674
|
+
return { success: false, skipped: false, index };
|
|
675
|
+
}
|
|
522
676
|
const errorMessage = `Failed to update record ${index + 1}: ${error.message || error}`;
|
|
523
677
|
errors.push(errorMessage);
|
|
524
678
|
callbacks?.onWarn?.(errorMessage);
|
|
525
|
-
return { success: false, index };
|
|
679
|
+
return { success: false, skipped: false, index };
|
|
526
680
|
}
|
|
527
681
|
});
|
|
528
682
|
const updateResults = await Promise.all(updatePromises);
|
|
529
|
-
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;
|
|
530
685
|
processed += updated;
|
|
531
686
|
if (options.verbose) {
|
|
532
687
|
callbacks?.onSuccess?.(`Completed ${updated}/${existingRecordsToUpdate.length} record updates`);
|
|
@@ -553,6 +708,9 @@ export class PullService {
|
|
|
553
708
|
return { success: true, index };
|
|
554
709
|
}
|
|
555
710
|
catch (error) {
|
|
711
|
+
if (this.keepUnsetKeyError(error, existingRecordsToUpdate.length + index, filePath, options.entity, unsetKeyErrors)) {
|
|
712
|
+
return { success: false, index };
|
|
713
|
+
}
|
|
556
714
|
const errorMessage = `Failed to process new record ${index + 1}: ${error.message || error}`;
|
|
557
715
|
errors.push(errorMessage);
|
|
558
716
|
callbacks?.onWarn?.(errorMessage);
|
|
@@ -574,6 +732,9 @@ export class PullService {
|
|
|
574
732
|
return { success: true, index };
|
|
575
733
|
}
|
|
576
734
|
catch (error) {
|
|
735
|
+
if (this.keepUnsetKeyError(error, existingRecordsToUpdate.length + index, undefined, options.entity, unsetKeyErrors)) {
|
|
736
|
+
return { success: false, index };
|
|
737
|
+
}
|
|
577
738
|
const errorMessage = `Failed to process new record ${index + 1}: ${error.message || error}`;
|
|
578
739
|
errors.push(errorMessage);
|
|
579
740
|
callbacks?.onWarn?.(errorMessage);
|
|
@@ -588,12 +749,30 @@ export class PullService {
|
|
|
588
749
|
}
|
|
589
750
|
}
|
|
590
751
|
}
|
|
752
|
+
this.throwFirstUnsetKeyError(unsetKeyErrors);
|
|
591
753
|
// If there were errors during parallel processing, throw them
|
|
592
754
|
if (errors.length > 0) {
|
|
593
755
|
throw new Error(`Parallel processing completed with ${errors.length} errors:\n${errors.join('\n')}`);
|
|
594
756
|
}
|
|
595
757
|
return { processed, updated, created, skipped };
|
|
596
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
|
+
}
|
|
597
776
|
async processRecord(record, primaryKey, targetDir, entityConfig, verbose, batchedRelatedData) {
|
|
598
777
|
const recordData = await this.recordProcessor.processRecord(record, primaryKey, targetDir, entityConfig, verbose, true, undefined, 0, new Set(), undefined, batchedRelatedData);
|
|
599
778
|
// Determine file path
|
|
@@ -650,24 +829,28 @@ export class PullService {
|
|
|
650
829
|
}
|
|
651
830
|
return batchedRelatedData.size > 0 ? batchedRelatedData : undefined;
|
|
652
831
|
}
|
|
653
|
-
|
|
654
|
-
|
|
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 = [];
|
|
655
838
|
for (const filePath of files) {
|
|
839
|
+
let fileData;
|
|
656
840
|
try {
|
|
657
|
-
|
|
658
|
-
const records = Array.isArray(fileData) ? fileData : [fileData];
|
|
659
|
-
for (const record of records) {
|
|
660
|
-
if (record.primaryKey) {
|
|
661
|
-
const lookupKey = createPrimaryKeyLookup(record.primaryKey);
|
|
662
|
-
recordsMap.set(lookupKey, { filePath, recordData: record });
|
|
663
|
-
}
|
|
664
|
-
}
|
|
841
|
+
fileData = await fs.readJson(filePath);
|
|
665
842
|
}
|
|
666
|
-
catch (
|
|
667
|
-
|
|
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
|
+
}
|
|
668
851
|
}
|
|
669
852
|
}
|
|
670
|
-
return
|
|
853
|
+
return entries;
|
|
671
854
|
}
|
|
672
855
|
async findEntityDirectories(entityName) {
|
|
673
856
|
const dirs = [];
|
|
@@ -757,13 +940,32 @@ export class PullService {
|
|
|
757
940
|
result.sync = newData.sync;
|
|
758
941
|
return result;
|
|
759
942
|
}
|
|
943
|
+
/**
|
|
944
|
+
* Backs up each existing file the write batch is about to rewrite, once per file. Files the
|
|
945
|
+
* batch will create fresh have nothing to back up.
|
|
946
|
+
*/
|
|
947
|
+
async backupPendingFiles(backupDirName, verbose, callbacks) {
|
|
948
|
+
for (const filePath of this.fileWriteBatch.getPendingFiles()) {
|
|
949
|
+
if (!(await fs.pathExists(filePath))) {
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
const backupPath = await this.createBackup(filePath, backupDirName);
|
|
953
|
+
if (verbose) {
|
|
954
|
+
callbacks?.onLog?.(`Backed up ${path.basename(filePath)} → ${path.relative(path.dirname(filePath), backupPath)}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Copies `filePath` to a timestamped `.backup` file under `backupDirName` (default `.backups`,
|
|
960
|
+
* relative to the file's directory). Throws if the copy fails: a pull asked to back up must not
|
|
961
|
+
* go on to rewrite a file it could not back up.
|
|
962
|
+
*/
|
|
760
963
|
async createBackup(filePath, backupDirName) {
|
|
761
964
|
const dir = path.dirname(filePath);
|
|
762
965
|
const fileName = path.basename(filePath);
|
|
763
|
-
const backupDir =
|
|
966
|
+
const backupDir = resolveBackupDirectory(dir, backupDirName);
|
|
764
967
|
// Ensure backup directory exists
|
|
765
968
|
await fs.ensureDir(backupDir);
|
|
766
|
-
// Track the backup directory for cleanup
|
|
767
969
|
this.createdBackupDirs.add(backupDir);
|
|
768
970
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
769
971
|
// Remove .json extension, add timestamp, then add .backup extension
|
|
@@ -771,12 +973,13 @@ export class PullService {
|
|
|
771
973
|
const backupPath = path.join(backupDir, backupFileName);
|
|
772
974
|
try {
|
|
773
975
|
await fs.copy(filePath, backupPath);
|
|
774
|
-
// Track the created backup file for cleanup
|
|
775
|
-
this.createdBackupFiles.push(backupPath);
|
|
776
976
|
}
|
|
777
977
|
catch (error) {
|
|
778
|
-
|
|
978
|
+
throw new Error(`Failed to back up ${filePath} to ${backupPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
779
979
|
}
|
|
980
|
+
this.createdBackupFiles.push(backupPath);
|
|
981
|
+
this.backupSources.set(backupPath, filePath);
|
|
982
|
+
return backupPath;
|
|
780
983
|
}
|
|
781
984
|
}
|
|
782
985
|
//# sourceMappingURL=PullService.js.map
|