@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.
Files changed (130) hide show
  1. package/README.md +117 -53
  2. package/dist/config.d.ts +5 -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 +33 -2
  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/entity-subclass-guard.d.ts +8 -7
  29. package/dist/lib/entity-subclass-guard.d.ts.map +1 -1
  30. package/dist/lib/entity-subclass-guard.js +20 -8
  31. package/dist/lib/entity-subclass-guard.js.map +1 -1
  32. package/dist/lib/env-derived-fields.d.ts +23 -0
  33. package/dist/lib/env-derived-fields.d.ts.map +1 -0
  34. package/dist/lib/env-derived-fields.js +53 -0
  35. package/dist/lib/env-derived-fields.js.map +1 -0
  36. package/dist/lib/env-display.d.ts +54 -0
  37. package/dist/lib/env-display.d.ts.map +1 -0
  38. package/dist/lib/env-display.js +156 -0
  39. package/dist/lib/env-display.js.map +1 -0
  40. package/dist/lib/env-reference.d.ts +104 -0
  41. package/dist/lib/env-reference.d.ts.map +1 -0
  42. package/dist/lib/env-reference.js +241 -0
  43. package/dist/lib/env-reference.js.map +1 -0
  44. package/dist/lib/env-sql-redactor.d.ts +74 -0
  45. package/dist/lib/env-sql-redactor.d.ts.map +1 -0
  46. package/dist/lib/env-sql-redactor.js +297 -0
  47. package/dist/lib/env-sql-redactor.js.map +1 -0
  48. package/dist/lib/existing-record-index.d.ts +70 -0
  49. package/dist/lib/existing-record-index.d.ts.map +1 -0
  50. package/dist/lib/existing-record-index.js +158 -0
  51. package/dist/lib/existing-record-index.js.map +1 -0
  52. package/dist/lib/ignore-directories.d.ts +10 -0
  53. package/dist/lib/ignore-directories.d.ts.map +1 -0
  54. package/dist/lib/ignore-directories.js +12 -0
  55. package/dist/lib/ignore-directories.js.map +1 -0
  56. package/dist/lib/json-record-lines.d.ts +17 -0
  57. package/dist/lib/json-record-lines.d.ts.map +1 -0
  58. package/dist/lib/json-record-lines.js +170 -0
  59. package/dist/lib/json-record-lines.js.map +1 -0
  60. package/dist/lib/json-subproperty-externalization.d.ts.map +1 -1
  61. package/dist/lib/json-subproperty-externalization.js +3 -1
  62. package/dist/lib/json-subproperty-externalization.js.map +1 -1
  63. package/dist/lib/lookup-reference.d.ts +38 -0
  64. package/dist/lib/lookup-reference.d.ts.map +1 -0
  65. package/dist/lib/lookup-reference.js +62 -0
  66. package/dist/lib/lookup-reference.js.map +1 -0
  67. package/dist/lib/provider-utils.d.ts.map +1 -1
  68. package/dist/lib/provider-utils.js +2 -4
  69. package/dist/lib/provider-utils.js.map +1 -1
  70. package/dist/lib/pull-option-overrides.d.ts +24 -0
  71. package/dist/lib/pull-option-overrides.d.ts.map +1 -0
  72. package/dist/lib/pull-option-overrides.js +39 -0
  73. package/dist/lib/pull-option-overrides.js.map +1 -0
  74. package/dist/lib/push-directories.d.ts +42 -0
  75. package/dist/lib/push-directories.d.ts.map +1 -0
  76. package/dist/lib/push-directories.js +71 -0
  77. package/dist/lib/push-directories.js.map +1 -0
  78. package/dist/lib/push-when.d.ts +38 -0
  79. package/dist/lib/push-when.d.ts.map +1 -0
  80. package/dist/lib/push-when.js +84 -0
  81. package/dist/lib/push-when.js.map +1 -0
  82. package/dist/lib/record-dependency-analyzer.d.ts +2 -0
  83. package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
  84. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  85. package/dist/lib/record-file-glob.d.ts +11 -0
  86. package/dist/lib/record-file-glob.d.ts.map +1 -0
  87. package/dist/lib/record-file-glob.js +10 -0
  88. package/dist/lib/record-file-glob.js.map +1 -0
  89. package/dist/lib/reference-parser.d.ts +3 -2
  90. package/dist/lib/reference-parser.d.ts.map +1 -1
  91. package/dist/lib/reference-parser.js +27 -27
  92. package/dist/lib/reference-parser.js.map +1 -1
  93. package/dist/lib/sync-engine.d.ts +52 -4
  94. package/dist/lib/sync-engine.d.ts.map +1 -1
  95. package/dist/lib/sync-engine.js +233 -61
  96. package/dist/lib/sync-engine.js.map +1 -1
  97. package/dist/lib/sync-state-manager.d.ts +7 -3
  98. package/dist/lib/sync-state-manager.d.ts.map +1 -1
  99. package/dist/lib/sync-state-manager.js +19 -4
  100. package/dist/lib/sync-state-manager.js.map +1 -1
  101. package/dist/lib/watch-file-filter.d.ts +31 -0
  102. package/dist/lib/watch-file-filter.d.ts.map +1 -0
  103. package/dist/lib/watch-file-filter.js +64 -0
  104. package/dist/lib/watch-file-filter.js.map +1 -0
  105. package/dist/plugins/index.d.ts.map +1 -1
  106. package/dist/plugins/index.js +55 -23
  107. package/dist/plugins/index.js.map +1 -1
  108. package/dist/services/FormattingService.d.ts +14 -7
  109. package/dist/services/FormattingService.d.ts.map +1 -1
  110. package/dist/services/FormattingService.js +22 -9
  111. package/dist/services/FormattingService.js.map +1 -1
  112. package/dist/services/PullService.d.ts +53 -1
  113. package/dist/services/PullService.d.ts.map +1 -1
  114. package/dist/services/PullService.js +323 -120
  115. package/dist/services/PullService.js.map +1 -1
  116. package/dist/services/PushService.d.ts +59 -0
  117. package/dist/services/PushService.d.ts.map +1 -1
  118. package/dist/services/PushService.js +448 -173
  119. package/dist/services/PushService.js.map +1 -1
  120. package/dist/services/ValidationService.d.ts +43 -8
  121. package/dist/services/ValidationService.d.ts.map +1 -1
  122. package/dist/services/ValidationService.js +338 -127
  123. package/dist/services/ValidationService.js.map +1 -1
  124. package/dist/services/WatchService.d.ts +26 -0
  125. package/dist/services/WatchService.d.ts.map +1 -1
  126. package/dist/services/WatchService.js +210 -75
  127. package/dist/services/WatchService.js.map +1 -1
  128. package/dist/types/validation.d.ts +6 -0
  129. package/dist/types/validation.d.ts.map +1 -1
  130. 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.CleanupBackupFiles();
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
- 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
+ }
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
- if (sinceTimestamp && !options.dryRun) {
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
- // Process record for multi-file
203
- const recordData = await this.recordProcessor.processRecord(record, primaryKey, targetDir, entityConfig, options.verbose, true, undefined, 0, new Set(), undefined, batchedRelatedData);
204
- 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 };
205
262
  }
206
263
  catch (error) {
207
- 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)}`;
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
- // Queue all records to single file for batched write
225
- if (allRecords.length > 0) {
226
- const fileName = options.multiFile.endsWith('.json') ? options.multiFile : `${options.multiFile}.json`;
227
- 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) {
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
- try {
337
- if (!isValidISOTimestamp(sinceTimestamp)) {
338
- throw new Error('Invalid --since timestamp format. Expected ISO 8601 (e.g., 2026-04-07T10:00:00Z)');
339
- }
340
- const rv = new RunView();
341
- const deletedFilter = `[__mj_DeletedAt] > '${sinceTimestamp}'`;
342
- const baseFilter = options.filter || '';
343
- const fullFilter = baseFilter
344
- ? `(${baseFilter}) AND ${deletedFilter}`
345
- : deletedFilter;
346
- const result = await rv.RunView({
347
- EntityName: options.entity,
348
- ExtraFilter: fullFilter,
349
- ResultType: 'simple',
350
- }, this.contextUser);
351
- if (!result.Success || result.Results.length === 0) {
352
- return;
353
- }
354
- const entityInfo = this.syncEngine.getEntityInfo(options.entity);
355
- const pkField = entityInfo?.PrimaryKeys?.[0]?.Name ?? 'ID';
356
- let removed = 0;
357
- for (const record of result.Results) {
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
- if (removed > 0) {
382
- 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
+ }
383
539
  }
384
540
  }
385
- catch (error) {
386
- // Non-fatal — log and continue. Entity may not support soft deletes
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
- // Extract original file path from backup path
408
- const backupDir = path.dirname(backupPath);
409
- const backupFileName = path.basename(backupPath);
410
- // Remove timestamp and .backup extension to get original filename
411
- const originalFileName = backupFileName.replace(/\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.backup$/, '.json');
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 build lookup map
440
- 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);
441
596
  if (options.verbose) {
442
- 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
+ }
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
- // Create lookup key
450
- const lookupKey = createPrimaryKeyLookup(primaryKey);
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, filePath: existingFileInfo.filePath });
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: ${lookupKey}`);
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): ${lookupKey}`);
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 updatePromises = existingRecordsToUpdate.map(async ({ record, primaryKey, filePath }, index) => {
484
- try {
485
- // Create backup if configured (only once per file)
486
- if (entityConfig.pull?.backupBeforeUpdate && !backedUpFiles.has(filePath)) {
487
- await this.createBackup(filePath, entityConfig.pull?.backupDirectory);
488
- backedUpFiles.add(filePath);
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, 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
+ }
506
658
  // Queue updated data for batched write
507
- if (Array.isArray(existingData)) {
508
- // Queue array update - batch will handle merging
509
- const primaryKeyLookup = createPrimaryKeyLookup(primaryKey);
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
- async loadExistingRecords(files, _entityInfo) {
654
- 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 = [];
655
838
  for (const filePath of files) {
839
+ let fileData;
656
840
  try {
657
- const fileData = await fs.readJson(filePath);
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 (error) {
667
- // 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
+ }
668
851
  }
669
852
  }
670
- return recordsMap;
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 = path.join(dir, backupDirName || '.backups');
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
- // Log error but don't throw
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