@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.
Files changed (143) hide show
  1. package/README.md +120 -52
  2. package/dist/config.d.ts +18 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/RecordProcessor.d.ts +98 -0
  10. package/dist/lib/RecordProcessor.d.ts.map +1 -1
  11. package/dist/lib/RecordProcessor.js +424 -22
  12. package/dist/lib/RecordProcessor.js.map +1 -1
  13. package/dist/lib/RelatedEntityHandler.d.ts +4 -1
  14. package/dist/lib/RelatedEntityHandler.d.ts.map +1 -1
  15. package/dist/lib/RelatedEntityHandler.js +49 -11
  16. package/dist/lib/RelatedEntityHandler.js.map +1 -1
  17. package/dist/lib/created-record-key.d.ts +34 -0
  18. package/dist/lib/created-record-key.d.ts.map +1 -0
  19. package/dist/lib/created-record-key.js +46 -0
  20. package/dist/lib/created-record-key.js.map +1 -0
  21. package/dist/lib/deletion-auditor.d.ts.map +1 -1
  22. package/dist/lib/deletion-auditor.js +34 -3
  23. package/dist/lib/deletion-auditor.js.map +1 -1
  24. package/dist/lib/entity-field-names.d.ts +11 -0
  25. package/dist/lib/entity-field-names.d.ts.map +1 -0
  26. package/dist/lib/entity-field-names.js +15 -0
  27. package/dist/lib/entity-field-names.js.map +1 -0
  28. package/dist/lib/env-derived-fields.d.ts +23 -0
  29. package/dist/lib/env-derived-fields.d.ts.map +1 -0
  30. package/dist/lib/env-derived-fields.js +53 -0
  31. package/dist/lib/env-derived-fields.js.map +1 -0
  32. package/dist/lib/env-display.d.ts +54 -0
  33. package/dist/lib/env-display.d.ts.map +1 -0
  34. package/dist/lib/env-display.js +156 -0
  35. package/dist/lib/env-display.js.map +1 -0
  36. package/dist/lib/env-reference.d.ts +104 -0
  37. package/dist/lib/env-reference.d.ts.map +1 -0
  38. package/dist/lib/env-reference.js +241 -0
  39. package/dist/lib/env-reference.js.map +1 -0
  40. package/dist/lib/env-sql-redactor.d.ts +74 -0
  41. package/dist/lib/env-sql-redactor.d.ts.map +1 -0
  42. package/dist/lib/env-sql-redactor.js +232 -0
  43. package/dist/lib/env-sql-redactor.js.map +1 -0
  44. package/dist/lib/existing-record-index.d.ts +70 -0
  45. package/dist/lib/existing-record-index.d.ts.map +1 -0
  46. package/dist/lib/existing-record-index.js +158 -0
  47. package/dist/lib/existing-record-index.js.map +1 -0
  48. package/dist/lib/ignore-directories.d.ts +10 -0
  49. package/dist/lib/ignore-directories.d.ts.map +1 -0
  50. package/dist/lib/ignore-directories.js +12 -0
  51. package/dist/lib/ignore-directories.js.map +1 -0
  52. package/dist/lib/json-record-lines.d.ts +17 -0
  53. package/dist/lib/json-record-lines.d.ts.map +1 -0
  54. package/dist/lib/json-record-lines.js +170 -0
  55. package/dist/lib/json-record-lines.js.map +1 -0
  56. package/dist/lib/json-subproperty-externalization.d.ts.map +1 -1
  57. package/dist/lib/json-subproperty-externalization.js +3 -1
  58. package/dist/lib/json-subproperty-externalization.js.map +1 -1
  59. package/dist/lib/lookup-reference.d.ts +38 -0
  60. package/dist/lib/lookup-reference.d.ts.map +1 -0
  61. package/dist/lib/lookup-reference.js +62 -0
  62. package/dist/lib/lookup-reference.js.map +1 -0
  63. package/dist/lib/lookup-sql.d.ts +108 -0
  64. package/dist/lib/lookup-sql.d.ts.map +1 -0
  65. package/dist/lib/lookup-sql.js +231 -0
  66. package/dist/lib/lookup-sql.js.map +1 -0
  67. package/dist/lib/natural-key-preflight.d.ts +86 -0
  68. package/dist/lib/natural-key-preflight.d.ts.map +1 -0
  69. package/dist/lib/natural-key-preflight.js +173 -0
  70. package/dist/lib/natural-key-preflight.js.map +1 -0
  71. package/dist/lib/provider-utils.d.ts.map +1 -1
  72. package/dist/lib/provider-utils.js +2 -4
  73. package/dist/lib/provider-utils.js.map +1 -1
  74. package/dist/lib/pull-option-overrides.d.ts +24 -0
  75. package/dist/lib/pull-option-overrides.d.ts.map +1 -0
  76. package/dist/lib/pull-option-overrides.js +39 -0
  77. package/dist/lib/pull-option-overrides.js.map +1 -0
  78. package/dist/lib/push-directories.d.ts +42 -0
  79. package/dist/lib/push-directories.d.ts.map +1 -0
  80. package/dist/lib/push-directories.js +71 -0
  81. package/dist/lib/push-directories.js.map +1 -0
  82. package/dist/lib/push-when.d.ts +38 -0
  83. package/dist/lib/push-when.d.ts.map +1 -0
  84. package/dist/lib/push-when.js +84 -0
  85. package/dist/lib/push-when.js.map +1 -0
  86. package/dist/lib/record-dependency-analyzer.d.ts +11 -2
  87. package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
  88. package/dist/lib/record-dependency-analyzer.js +48 -71
  89. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  90. package/dist/lib/record-file-glob.d.ts +11 -0
  91. package/dist/lib/record-file-glob.d.ts.map +1 -0
  92. package/dist/lib/record-file-glob.js +10 -0
  93. package/dist/lib/record-file-glob.js.map +1 -0
  94. package/dist/lib/reference-parser.d.ts +3 -2
  95. package/dist/lib/reference-parser.d.ts.map +1 -1
  96. package/dist/lib/reference-parser.js +27 -27
  97. package/dist/lib/reference-parser.js.map +1 -1
  98. package/dist/lib/schema-placeholders.d.ts +29 -0
  99. package/dist/lib/schema-placeholders.d.ts.map +1 -0
  100. package/dist/lib/schema-placeholders.js +72 -0
  101. package/dist/lib/schema-placeholders.js.map +1 -0
  102. package/dist/lib/sql-logger.d.ts +9 -1
  103. package/dist/lib/sql-logger.d.ts.map +1 -1
  104. package/dist/lib/sql-logger.js +12 -7
  105. package/dist/lib/sql-logger.js.map +1 -1
  106. package/dist/lib/sql-set-line.d.ts +26 -0
  107. package/dist/lib/sql-set-line.d.ts.map +1 -0
  108. package/dist/lib/sql-set-line.js +84 -0
  109. package/dist/lib/sql-set-line.js.map +1 -0
  110. package/dist/lib/sync-engine.d.ts +58 -4
  111. package/dist/lib/sync-engine.d.ts.map +1 -1
  112. package/dist/lib/sync-engine.js +243 -59
  113. package/dist/lib/sync-engine.js.map +1 -1
  114. package/dist/lib/sync-state-manager.d.ts +7 -3
  115. package/dist/lib/sync-state-manager.d.ts.map +1 -1
  116. package/dist/lib/sync-state-manager.js +19 -4
  117. package/dist/lib/sync-state-manager.js.map +1 -1
  118. package/dist/lib/watch-file-filter.d.ts +31 -0
  119. package/dist/lib/watch-file-filter.d.ts.map +1 -0
  120. package/dist/lib/watch-file-filter.js +64 -0
  121. package/dist/lib/watch-file-filter.js.map +1 -0
  122. package/dist/plugins/index.d.ts.map +1 -1
  123. package/dist/plugins/index.js +44 -14
  124. package/dist/plugins/index.js.map +1 -1
  125. package/dist/services/PullService.d.ts +41 -1
  126. package/dist/services/PullService.d.ts.map +1 -1
  127. package/dist/services/PullService.js +261 -101
  128. package/dist/services/PullService.js.map +1 -1
  129. package/dist/services/PushService.d.ts +80 -0
  130. package/dist/services/PushService.d.ts.map +1 -1
  131. package/dist/services/PushService.js +531 -189
  132. package/dist/services/PushService.js.map +1 -1
  133. package/dist/services/ValidationService.d.ts +43 -8
  134. package/dist/services/ValidationService.d.ts.map +1 -1
  135. package/dist/services/ValidationService.js +338 -127
  136. package/dist/services/ValidationService.js.map +1 -1
  137. package/dist/services/WatchService.d.ts +28 -0
  138. package/dist/services/WatchService.d.ts.map +1 -1
  139. package/dist/services/WatchService.js +226 -74
  140. package/dist/services/WatchService.js.map +1 -1
  141. package/dist/types/validation.d.ts +6 -0
  142. package/dist/types/validation.d.ts.map +1 -1
  143. 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
- callbacks?.onWarn?.('File changes have been rolled back due to operation failure');
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
- if (sinceTimestamp && !options.dryRun) {
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
- // Process record for multi-file
233
- const recordData = await this.recordProcessor.processRecord(record, primaryKey, targetDir, entityConfig, options.verbose, true, undefined, 0, new Set(), undefined, batchedRelatedData);
234
- return { success: true, recordData, index };
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
- const errorMessage = `Failed to process record ${index + 1}: ${error.message || error}`;
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
- // Queue all records to single file for batched write
255
- if (allRecords.length > 0) {
256
- const fileName = options.multiFile.endsWith('.json') ? options.multiFile : `${options.multiFile}.json`;
257
- const filePath = path.join(targetDir, fileName);
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
- try {
367
- if (!isValidISOTimestamp(sinceTimestamp)) {
368
- throw new Error('Invalid --since timestamp format. Expected ISO 8601 (e.g., 2026-04-07T10:00:00Z)');
369
- }
370
- const rv = new RunView();
371
- const deletedFilter = `[__mj_DeletedAt] > '${sinceTimestamp}'`;
372
- const baseFilter = options.filter || '';
373
- const fullFilter = baseFilter
374
- ? `(${baseFilter}) AND ${deletedFilter}`
375
- : deletedFilter;
376
- const result = await rv.RunView({
377
- EntityName: options.entity,
378
- ExtraFilter: fullFilter,
379
- ResultType: 'simple',
380
- }, this.contextUser);
381
- if (!result.Success || result.Results.length === 0) {
382
- return;
383
- }
384
- const entityInfo = this.syncEngine.getEntityInfo(options.entity);
385
- const pkField = entityInfo?.PrimaryKeys?.[0]?.Name ?? 'ID';
386
- let removed = 0;
387
- for (const record of result.Results) {
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
- if (removed > 0) {
412
- callbacks?.onSuccess?.(`Removed ${removed} files for soft-deleted records`);
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
- catch (error) {
416
- // Non-fatal — log and continue. Entity may not support soft deletes
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 build lookup map
469
- const existingRecordsMap = await this.loadExistingRecords(existingFiles, entityInfo);
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 ${existingRecordsMap.size} existing records from files`);
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
- // Create lookup key
479
- const lookupKey = createPrimaryKeyLookup(primaryKey);
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, filePath: existingFileInfo.filePath });
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: ${lookupKey}`);
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): ${lookupKey}`);
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 updatePromises = existingRecordsToUpdate.map(async ({ record, primaryKey, filePath }, index) => {
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
- // Load existing file data
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, entityConfig.pull?.mergeStrategy || 'merge', entityConfig.pull?.preserveFields || []);
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 (Array.isArray(existingData)) {
531
- // Queue array update - batch will handle merging
532
- const primaryKeyLookup = createPrimaryKeyLookup(primaryKey);
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
- async loadExistingRecords(files, _entityInfo) {
677
- const recordsMap = new Map();
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
- const fileData = await fs.readJson(filePath);
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 (error) {
690
- // Skip files that can't be parsed
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 recordsMap;
853
+ return entries;
694
854
  }
695
855
  async findEntityDirectories(entityName) {
696
856
  const dirs = [];