@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
|
@@ -8,7 +8,7 @@ import { IsStringSQLType } from '@memberjunction/sql-dialect';
|
|
|
8
8
|
import { SyncEngine, DeferrableLookupError } from '../lib/sync-engine.js';
|
|
9
9
|
import { SyncMetadataEngine } from '../lib/sync-metadata-engine.js';
|
|
10
10
|
import { BatchContextIndex } from '../lib/batch-context-index.js';
|
|
11
|
-
import { loadEntityConfig
|
|
11
|
+
import { loadEntityConfig } from '../config.js';
|
|
12
12
|
import { FileBackupManager } from '../lib/file-backup-manager.js';
|
|
13
13
|
import { configManager } from '../lib/config-manager.js';
|
|
14
14
|
import { SQLLogger } from '../lib/sql-logger.js';
|
|
@@ -19,10 +19,18 @@ import { GraphProviderPool, ProbeIndependentInstances } from '../lib/graph-provi
|
|
|
19
19
|
import { ResolveDirectoryMode, GraphBatchSizeFor, IsolatedModeWarning, UnusedBatchSizeWarning, } from '../lib/push-write-mode.js';
|
|
20
20
|
import { PushAbortedError, DescribeCommitFailure, DescribeRollbackOutcome } from '../lib/push-outcome.js';
|
|
21
21
|
import { JsonPreprocessor } from '../lib/json-preprocessor.js';
|
|
22
|
-
import {
|
|
22
|
+
import { ResolvePushFolders, SkippedDirectoryMessage } from '../lib/push-directories.js';
|
|
23
23
|
import { DeletionAuditor } from '../lib/deletion-auditor.js';
|
|
24
|
-
import {
|
|
24
|
+
import { DescribeMissingEntitySubclass } from '../lib/entity-subclass-guard.js';
|
|
25
25
|
import { DeletionReportGenerator } from '../lib/deletion-report-generator.js';
|
|
26
|
+
import { EnvVariableNotSetError, OptionalEnvMissingError, OptionalEnvSkipMessage, ParseEnvReference } from '../lib/env-reference.js';
|
|
27
|
+
import { RecordStartLine } from '../lib/json-record-lines.js';
|
|
28
|
+
import { EnvDisplayMask, EnvSourceLabel, ENV_HIDDEN_VALUE, MaskedError } from '../lib/env-display.js';
|
|
29
|
+
import { EnvSqlLog, AddSkippedOptionalEnvField } from '../lib/env-sql-redactor.js';
|
|
30
|
+
import { CreatedRecordKeyFields, IsKeyReference, KeyFieldValue } from '../lib/created-record-key.js';
|
|
31
|
+
import { EntityFieldName, RecordFieldNames } from '../lib/entity-field-names.js';
|
|
32
|
+
import { METADATA_KEYWORDS } from '../constants/metadata-keywords.js';
|
|
33
|
+
import { DEFAULT_RECORD_FILE_PATTERN, RECORD_FILE_GLOB_OPTIONS } from '../lib/record-file-glob.js';
|
|
26
34
|
import { resolveCollectionRelationship } from '@memberjunction/record-graph';
|
|
27
35
|
function emptyPushTotals() {
|
|
28
36
|
return { created: 0, updated: 0, unchanged: 0, deleted: 0, skipped: 0, deferred: 0, errors: 0 };
|
|
@@ -64,11 +72,16 @@ export class PushService {
|
|
|
64
72
|
this.graphBatchSize = 1;
|
|
65
73
|
/** Counts so far, so a failed push can still report what it did. */
|
|
66
74
|
this.runningTotals = emptyPushTotals();
|
|
75
|
+
/** The open SQL log's redactor and lookup-create hook; null when no log is open. */
|
|
76
|
+
this.envSqlLog = null;
|
|
77
|
+
/** Composition child fields waiting for their owner's save, by the owner record's resolution collector. */
|
|
78
|
+
this.pendingEnvSqlChildFields = new WeakMap();
|
|
67
79
|
/** Non-atomic mode: creates and updates that were committed outside the push transaction. */
|
|
68
80
|
this.committedWrites = [];
|
|
69
81
|
/** Incremental state, applied only after the push commits. Keyed by path relative to the sync root. */
|
|
70
82
|
this.pendingChecksums = new Map();
|
|
71
83
|
this.pushedEntityDirs = [];
|
|
84
|
+
this.skippedDirectories = [];
|
|
72
85
|
/** Errors whose record was already sent to onRecordError, so a caller does not report it twice. */
|
|
73
86
|
this.reportedRecordErrors = new WeakSet();
|
|
74
87
|
this.syncEngine = syncEngine;
|
|
@@ -179,14 +192,18 @@ export class PushService {
|
|
|
179
192
|
this.committedWrites = [];
|
|
180
193
|
this.pendingChecksums.clear();
|
|
181
194
|
this.pushedEntityDirs = [];
|
|
195
|
+
this.skippedDirectories = [];
|
|
182
196
|
this.directoryModes.clear();
|
|
183
197
|
this.runningTotals = emptyPushTotals();
|
|
184
198
|
this.sqlLogFilePath = undefined;
|
|
199
|
+
this.closeEnvSqlRedactor();
|
|
185
200
|
const fileBackupManager = new FileBackupManager();
|
|
186
201
|
// Load sync config for SQL logging settings and autoCreateMissingRecords flag
|
|
187
|
-
//
|
|
188
|
-
const
|
|
189
|
-
|
|
202
|
+
// With the dir option, load from the sync root at or above that directory, otherwise from the original CWD
|
|
203
|
+
const folders = await ResolvePushFolders(configManager.getOriginalCwd(), options.dir, options.include, options.exclude);
|
|
204
|
+
const configDir = folders.ConfigDir;
|
|
205
|
+
const syncRootDir = folders.SyncRootDir;
|
|
206
|
+
this.syncConfig = folders.SyncConfig;
|
|
190
207
|
// Display warnings for special flags that are enabled
|
|
191
208
|
if (this.syncConfig?.push?.alwaysPush && !options.dryRun) {
|
|
192
209
|
callbacks?.onWarn?.('\n⚡ WARNING: alwaysPush is enabled - ALL records will be saved to database regardless of changes\n');
|
|
@@ -197,7 +214,7 @@ export class PushService {
|
|
|
197
214
|
if (options.verbose) {
|
|
198
215
|
callbacks?.onLog?.(`Original working directory: ${configManager.getOriginalCwd()}`);
|
|
199
216
|
callbacks?.onLog?.(`Config directory (with dir option): ${configDir}`);
|
|
200
|
-
callbacks?.onLog?.(`Config file path: ${path.join(
|
|
217
|
+
callbacks?.onLog?.(`Config file path: ${path.join(syncRootDir, '.mj-sync.json')}`);
|
|
201
218
|
callbacks?.onLog?.(`Full sync config loaded: ${JSON.stringify(this.syncConfig, null, 2)}`);
|
|
202
219
|
callbacks?.onLog?.(`SQL logging config: ${JSON.stringify(this.syncConfig?.sqlLogging)}`);
|
|
203
220
|
}
|
|
@@ -210,13 +227,12 @@ export class PushService {
|
|
|
210
227
|
// Setup SQL logging session with the provider if enabled
|
|
211
228
|
let sqlLoggingSession = null;
|
|
212
229
|
try {
|
|
213
|
-
sqlLoggingSession = await this.startSqlLogging(sqlLogger, options,
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
throw new Error('No entity directories found');
|
|
230
|
+
sqlLoggingSession = await this.startSqlLogging(sqlLogger, options, syncRootDir, callbacks);
|
|
231
|
+
const selection = folders.SelectDirectories();
|
|
232
|
+
const entityDirs = selection.Included;
|
|
233
|
+
this.skippedDirectories = selection.Skipped;
|
|
234
|
+
for (const skipped of selection.Skipped) {
|
|
235
|
+
callbacks?.onLog?.(SkippedDirectoryMessage(configDir, skipped));
|
|
220
236
|
}
|
|
221
237
|
await this.applyWritePlan(entityDirs, options, callbacks);
|
|
222
238
|
await this.preloadMetadata(entityDirs, options, callbacks);
|
|
@@ -253,7 +269,8 @@ export class PushService {
|
|
|
253
269
|
deferred: 0,
|
|
254
270
|
errors: 0,
|
|
255
271
|
warnings: this.warnings,
|
|
256
|
-
changeLog: this.changeDetails
|
|
272
|
+
changeLog: this.changeDetails,
|
|
273
|
+
SkippedDirectories: this.skippedDirectories
|
|
257
274
|
};
|
|
258
275
|
}
|
|
259
276
|
}
|
|
@@ -263,7 +280,7 @@ export class PushService {
|
|
|
263
280
|
if (!options.dryRun) {
|
|
264
281
|
await transactionManager.beginTransaction();
|
|
265
282
|
}
|
|
266
|
-
const totals = await this.runPushInTransaction({ entityDirs, deletionAudit, options, fileBackupManager, callbacks, configDir }, transactionManager);
|
|
283
|
+
const totals = await this.runPushInTransaction({ entityDirs, deletionAudit, options, fileBackupManager, callbacks, syncRootDir, belowSyncRoot: syncRootDir !== configDir }, transactionManager);
|
|
267
284
|
if (!options.dryRun) {
|
|
268
285
|
await fileBackupManager.cleanup();
|
|
269
286
|
if (options.verbose) {
|
|
@@ -281,16 +298,19 @@ export class PushService {
|
|
|
281
298
|
await sqlLoggingSession.dispose();
|
|
282
299
|
if (hadStatements) {
|
|
283
300
|
sqlLogPath = filePath;
|
|
301
|
+
this.addEnvSqlWarnings(filePath, callbacks);
|
|
284
302
|
if (options.verbose) {
|
|
285
303
|
callbacks?.onLog?.(`📝 SQL log written to: ${filePath}`);
|
|
286
304
|
}
|
|
287
305
|
}
|
|
306
|
+
this.closeEnvSqlRedactor();
|
|
288
307
|
}
|
|
289
308
|
return {
|
|
290
309
|
...totals,
|
|
291
310
|
warnings: this.warnings,
|
|
292
311
|
sqlLogPath,
|
|
293
|
-
changeLog: this.changeDetails
|
|
312
|
+
changeLog: this.changeDetails,
|
|
313
|
+
SkippedDirectories: this.skippedDirectories
|
|
294
314
|
};
|
|
295
315
|
}
|
|
296
316
|
catch (error) {
|
|
@@ -308,10 +328,16 @@ export class PushService {
|
|
|
308
328
|
if (sqlLoggingSession) {
|
|
309
329
|
try {
|
|
310
330
|
await sqlLoggingSession.dispose();
|
|
331
|
+
if (await fs.pathExists(sqlLoggingSession.filePath)) {
|
|
332
|
+
this.addEnvSqlWarnings(sqlLoggingSession.filePath, callbacks);
|
|
333
|
+
}
|
|
311
334
|
}
|
|
312
335
|
catch (disposeError) {
|
|
313
336
|
callbacks?.onWarn?.(`Failed to close SQL logging session: ${disposeError}`);
|
|
314
337
|
}
|
|
338
|
+
finally {
|
|
339
|
+
this.closeEnvSqlRedactor();
|
|
340
|
+
}
|
|
315
341
|
}
|
|
316
342
|
throw error;
|
|
317
343
|
}
|
|
@@ -321,7 +347,7 @@ export class PushService {
|
|
|
321
347
|
return this.Push(options, callbacks);
|
|
322
348
|
}
|
|
323
349
|
/** Open the SQL logging session when the config asks for one and this is not a dry run. */
|
|
324
|
-
async startSqlLogging(sqlLogger, options,
|
|
350
|
+
async startSqlLogging(sqlLogger, options, syncRootDir, callbacks) {
|
|
325
351
|
let sqlLoggingSession = null;
|
|
326
352
|
if (sqlLogger.enabled && !options.dryRun) {
|
|
327
353
|
const provider = Metadata.Provider; // global-provider-ok: metadata sync operates on the configured provider only
|
|
@@ -336,14 +362,16 @@ export class PushService {
|
|
|
336
362
|
const filename = this.syncConfig?.sqlLogging?.formatAsMigration
|
|
337
363
|
? `MetadataSync_Push_${timestamp}.sql`
|
|
338
364
|
: `push_${timestamp}.sql`;
|
|
339
|
-
//
|
|
340
|
-
const outputDir = path.join(
|
|
365
|
+
// The SQL log folder is relative to the sync root
|
|
366
|
+
const outputDir = path.join(syncRootDir, this.syncConfig?.sqlLogging?.outputDirectory || './sql-log-push');
|
|
341
367
|
const filepath = path.join(outputDir, filename);
|
|
342
368
|
// Ensure the directory exists
|
|
343
369
|
await fs.ensureDir(path.dirname(filepath));
|
|
344
370
|
// Create the SQL logging session
|
|
345
371
|
this.sqlLogFilePath = filepath;
|
|
372
|
+
this.envSqlLog = new EnvSqlLog(this.syncEngine);
|
|
346
373
|
sqlLoggingSession = await provider.CreateSqlLogger(filepath, {
|
|
374
|
+
TransformStatement: this.envSqlLog.Redactor.Transform,
|
|
347
375
|
formatAsMigration: this.syncConfig?.sqlLogging?.formatAsMigration || false,
|
|
348
376
|
description: 'MetadataSync push operation',
|
|
349
377
|
statementTypes: "mutations",
|
|
@@ -419,8 +447,16 @@ export class PushService {
|
|
|
419
447
|
catch (cleanupError) {
|
|
420
448
|
callbacks?.onWarn?.(`Failed to clean up SQL logging session: ${cleanupError}`);
|
|
421
449
|
}
|
|
450
|
+
finally {
|
|
451
|
+
this.closeEnvSqlRedactor();
|
|
452
|
+
}
|
|
422
453
|
}
|
|
423
454
|
}
|
|
455
|
+
/** Drops the SQL log redactor and this push's lookup-create hook, after the log is closed. */
|
|
456
|
+
closeEnvSqlRedactor() {
|
|
457
|
+
this.envSqlLog?.Close();
|
|
458
|
+
this.envSqlLog = null;
|
|
459
|
+
}
|
|
424
460
|
/**
|
|
425
461
|
* Decide, once and up front, how each entity directory writes: shared by default, isolated where
|
|
426
462
|
* the entity (or the root, or the CLI flag) asks for it. Deciding it here means a push can never
|
|
@@ -526,7 +562,7 @@ export class PushService {
|
|
|
526
562
|
committed = true;
|
|
527
563
|
// PHASE 3: files that contained deletions, then the incremental state
|
|
528
564
|
await this.writeDeferredFiles(run.options, run.callbacks);
|
|
529
|
-
await this.persistIncrementalState(run.
|
|
565
|
+
await this.persistIncrementalState(run.syncRootDir, run.belowSyncRoot);
|
|
530
566
|
}
|
|
531
567
|
return totals;
|
|
532
568
|
}
|
|
@@ -653,7 +689,7 @@ export class PushService {
|
|
|
653
689
|
: '❌ The push transaction was still open when the push ended, and rolling it back failed.');
|
|
654
690
|
}
|
|
655
691
|
/** Apply incremental checksums and push timestamps. Called only after the push commits. */
|
|
656
|
-
async persistIncrementalState(syncRootDir) {
|
|
692
|
+
async persistIncrementalState(syncRootDir, belowSyncRoot) {
|
|
657
693
|
if (!this.stateManager) {
|
|
658
694
|
return;
|
|
659
695
|
}
|
|
@@ -664,7 +700,7 @@ export class PushService {
|
|
|
664
700
|
for (const relativeEntityDir of this.pushedEntityDirs) {
|
|
665
701
|
this.stateManager.setLastPushTimestamp(relativeEntityDir, pushedAt);
|
|
666
702
|
}
|
|
667
|
-
await this.stateManager.pruneStaleChecksums(syncRootDir);
|
|
703
|
+
await this.stateManager.pruneStaleChecksums(syncRootDir, belowSyncRoot ? this.pushedEntityDirs : undefined);
|
|
668
704
|
await this.stateManager.save();
|
|
669
705
|
}
|
|
670
706
|
/** PHASE 1 over every entity directory, in order. */
|
|
@@ -686,7 +722,7 @@ export class PushService {
|
|
|
686
722
|
this.useDirectoryMode(entityDir, options);
|
|
687
723
|
this.announceDirectory(dirName, progressPrefix, entityConfig.entity, entityDir, options, callbacks);
|
|
688
724
|
const firstChange = this.changeDetails.length;
|
|
689
|
-
const result = await this.processEntityDirectory(entityDir, entityConfig, options, run.fileBackupManager, callbacks, run.
|
|
725
|
+
const result = await this.processEntityDirectory(entityDir, entityConfig, options, run.fileBackupManager, callbacks, run.syncRootDir);
|
|
690
726
|
// Nested relatedEntities land in changeDetails under their own entity name, so the
|
|
691
727
|
// directory's configured entity is not what decides the reload (MJ#4836).
|
|
692
728
|
await this.reloadMetadataIfTouched(firstChange, options.dryRun);
|
|
@@ -752,20 +788,14 @@ export class PushService {
|
|
|
752
788
|
let errors = 0;
|
|
753
789
|
// Issue #4199: a push against an entity whose subclass is not loaded in this process
|
|
754
790
|
// "succeeds" with a generic BaseEntity and silently skips the entity's custom logic. Say so.
|
|
755
|
-
const subclassWarning =
|
|
791
|
+
const subclassWarning = DescribeMissingEntitySubclass(String(entityConfig.entity ?? ''), { DryRun: options.dryRun });
|
|
756
792
|
if (subclassWarning) {
|
|
757
793
|
this.warnings.push(subclassWarning);
|
|
758
794
|
callbacks?.onWarn?.(`⚠️ ${subclassWarning}`);
|
|
759
795
|
}
|
|
760
796
|
// Find all JSON files in the directory
|
|
761
|
-
const pattern = entityConfig.filePattern ||
|
|
762
|
-
const files = await fastGlob(pattern, {
|
|
763
|
-
cwd: entityDir,
|
|
764
|
-
absolute: true,
|
|
765
|
-
onlyFiles: true,
|
|
766
|
-
dot: true,
|
|
767
|
-
ignore: ['**/node_modules/**', '**/.mj-*.json']
|
|
768
|
-
});
|
|
797
|
+
const pattern = entityConfig.filePattern || DEFAULT_RECORD_FILE_PATTERN;
|
|
798
|
+
const files = await fastGlob(pattern, { ...RECORD_FILE_GLOB_OPTIONS, cwd: entityDir });
|
|
769
799
|
if (options.verbose) {
|
|
770
800
|
callbacks?.onLog?.(`Found ${files.length} files to process`);
|
|
771
801
|
}
|
|
@@ -818,6 +848,9 @@ export class PushService {
|
|
|
818
848
|
// Analyze dependencies and get sorted records
|
|
819
849
|
const analyzer = new RecordDependencyAnalyzer();
|
|
820
850
|
const analysisResult = await analyzer.analyzeFileRecords(records, entityConfig.entity);
|
|
851
|
+
for (const flattened of analysisResult.sortedRecords) {
|
|
852
|
+
flattened.FilePath = filePath;
|
|
853
|
+
}
|
|
821
854
|
if (analysisResult.circularDependencies.length > 0) {
|
|
822
855
|
callbacks?.onWarn?.(`⚠️ Circular dependencies detected in ${filePath}`);
|
|
823
856
|
for (const cycle of analysisResult.circularDependencies) {
|
|
@@ -1056,8 +1089,8 @@ export class PushService {
|
|
|
1056
1089
|
pendingWrites.set(outcome.graphId, writes);
|
|
1057
1090
|
}
|
|
1058
1091
|
throwRecordFailure(failure, callbacks) {
|
|
1059
|
-
const err = failure.error instanceof Error ? failure.error : new Error(String(failure.error));
|
|
1060
1092
|
const rec = failure.record;
|
|
1093
|
+
const err = this.locateRecordError(failure.error instanceof Error ? failure.error : new Error(String(failure.error)), rec);
|
|
1061
1094
|
callbacks?.onLog?.(`\n❌ Processing failed for ${rec.entityName} at ${rec.path}`);
|
|
1062
1095
|
callbacks?.onLog?.(` ${err.message}\n`);
|
|
1063
1096
|
if (err.stack) {
|
|
@@ -1065,6 +1098,33 @@ export class PushService {
|
|
|
1065
1098
|
}
|
|
1066
1099
|
throw err;
|
|
1067
1100
|
}
|
|
1101
|
+
/**
|
|
1102
|
+
* For an unset `@env:` variable, an error that names the record file with the record's start line,
|
|
1103
|
+
* the entity and the field: `/abs/file.json:12: Entity fields.Name uses @env:VAR, which is not set`.
|
|
1104
|
+
* Other errors are returned as they are.
|
|
1105
|
+
*/
|
|
1106
|
+
locateRecordError(error, record) {
|
|
1107
|
+
if (!(error instanceof EnvVariableNotSetError)) {
|
|
1108
|
+
return error;
|
|
1109
|
+
}
|
|
1110
|
+
const field = error.Field ?? 'a field';
|
|
1111
|
+
const message = `${this.recordLocation(record)}: ${error.EntityName ?? record.entityName} ${field} uses @env:${error.VariableName}, which is not set`;
|
|
1112
|
+
return new Error(message, { cause: error });
|
|
1113
|
+
}
|
|
1114
|
+
/** `/abs/file.json:12` for a record from a file (the line where the record starts, when found), else the record path. */
|
|
1115
|
+
recordLocation(record) {
|
|
1116
|
+
if (!record.FilePath) {
|
|
1117
|
+
return record.path;
|
|
1118
|
+
}
|
|
1119
|
+
const filePath = path.resolve(record.FilePath);
|
|
1120
|
+
try {
|
|
1121
|
+
const line = RecordStartLine(fs.readFileSync(filePath, 'utf8'), record.path);
|
|
1122
|
+
return line === undefined ? filePath : `${filePath}:${line}`;
|
|
1123
|
+
}
|
|
1124
|
+
catch {
|
|
1125
|
+
return filePath;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1068
1128
|
async processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig, allowDefer = true, recordProvider) {
|
|
1069
1129
|
const metadata = new Metadata(); // global-provider-ok: metadata sync operates on the configured provider only
|
|
1070
1130
|
const { record, entityName, parentContext, id: recordId } = flattenedRecord;
|
|
@@ -1080,13 +1140,21 @@ export class PushService {
|
|
|
1080
1140
|
// Use the unique record ID from the flattened record for batch context
|
|
1081
1141
|
// This ensures we can properly find parent entities even when they're new
|
|
1082
1142
|
const lookupKey = recordId;
|
|
1083
|
-
// Check if already in batch context
|
|
1143
|
+
// Check if already in batch context; the deferred retry runs again for a record the main pass added
|
|
1084
1144
|
const existingEntry = batchContext.get(lookupKey);
|
|
1085
|
-
if (existingEntry) {
|
|
1145
|
+
if (existingEntry && allowDefer) {
|
|
1086
1146
|
// Already processed
|
|
1087
1147
|
return { status: 'unchanged', isDuplicate: true };
|
|
1088
1148
|
}
|
|
1089
1149
|
let entity;
|
|
1150
|
+
// Parent entity from the batch context, for @parent refs in primaryKey
|
|
1151
|
+
let parentEntity = null;
|
|
1152
|
+
if (parentContext) {
|
|
1153
|
+
const parentRecordId = flattenedRecord.dependencies.values().next().value;
|
|
1154
|
+
if (parentRecordId) {
|
|
1155
|
+
parentEntity = batchContext.get(parentRecordId) || null;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1090
1158
|
// Record-level skip (--incremental only): if the record has a stored
|
|
1091
1159
|
// checksum and it matches the current fields, nothing changed — skip
|
|
1092
1160
|
// without touching the DB. The checksum is embedded in the JSON
|
|
@@ -1100,16 +1168,20 @@ export class PushService {
|
|
|
1100
1168
|
// drift-correction behavior single-env workflows rely on, and prevents
|
|
1101
1169
|
// silent no-ops when seeding a fresh DB from JSON files that already
|
|
1102
1170
|
// carry stored checksums from another environment.
|
|
1103
|
-
|
|
1171
|
+
// The deferred retry (allowDefer false) never takes this shortcut: the first pass left fields unsaved
|
|
1172
|
+
if (allowDefer && options.incremental && record.sync?.checksum && record.fields && record.primaryKey && !record.deleteRecord) {
|
|
1104
1173
|
// Use calculateChecksumWithFileContent so @file: reference changes are detected.
|
|
1105
1174
|
// This reads local files (fast) rather than hitting the DB (slow). The stored
|
|
1106
1175
|
// checksum was also computed with file content and composition payloads, so the comparison is apples-to-apples.
|
|
1107
1176
|
const checksumPayload = this.buildRecordChecksumPayload(record);
|
|
1108
1177
|
const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(checksumPayload, entityDir);
|
|
1109
1178
|
if (currentChecksum === record.sync.checksum) {
|
|
1110
|
-
// Lightweight stub — just enough for @parent:ID and @lookup resolution.
|
|
1111
|
-
//
|
|
1112
|
-
|
|
1179
|
+
// Lightweight stub — just enough for @parent:ID and @lookup resolution. No entity load.
|
|
1180
|
+
// @env:, @parent: and @owner: key values are resolved without a DB call, so a child's @parent:
|
|
1181
|
+
// reference copies the value; other key values (such as @lookup:) stay raw.
|
|
1182
|
+
const keyCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map() };
|
|
1183
|
+
const shortcutKey = await this.resolvePrimaryKey(record.primaryKey, entityDir, parentEntity, batchContext, keyCollector, recordProvider, true);
|
|
1184
|
+
const allFields = { ...record.fields, ...shortcutKey };
|
|
1113
1185
|
const stub = {
|
|
1114
1186
|
EntityInfo: { Name: entityName, PrimaryKeys: Object.keys(record.primaryKey).map(k => ({ Name: k })), Fields: Object.keys(allFields).map(k => ({ Name: k })) },
|
|
1115
1187
|
Get: (field) => allFields[field],
|
|
@@ -1126,37 +1198,18 @@ export class PushService {
|
|
|
1126
1198
|
if (!entity) {
|
|
1127
1199
|
throw new Error(`Failed to create entity object for ${entityName}`);
|
|
1128
1200
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
if (parentContext) {
|
|
1132
|
-
const parentRecordId = flattenedRecord.dependencies.values().next().value;
|
|
1133
|
-
if (parentRecordId) {
|
|
1134
|
-
parentEntity = batchContext.get(parentRecordId) || null;
|
|
1135
|
-
}
|
|
1136
|
-
if (!parentEntity) {
|
|
1137
|
-
throw new Error(`Parent entity not found in batch context for ${entityName}. Parent dependencies: ${Array.from(flattenedRecord.dependencies).join(', ')}`);
|
|
1138
|
-
}
|
|
1201
|
+
if (parentContext && !parentEntity) {
|
|
1202
|
+
throw new Error(`Parent entity not found in batch context for ${entityName}. Parent dependencies: ${Array.from(flattenedRecord.dependencies).join(', ')}`);
|
|
1139
1203
|
}
|
|
1140
1204
|
// Check if record exists
|
|
1141
1205
|
// Process primaryKey values through processFieldValue to resolve @lookup, @parent, etc.
|
|
1142
1206
|
// Create a resolution collector to track @lookup and @parent resolutions
|
|
1143
|
-
const resolutionCollector = { notes: [], fieldPrefix: 'primaryKey' };
|
|
1207
|
+
const resolutionCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map() };
|
|
1208
|
+
// Reads the same EnvFields map, so fields recorded later are masked too
|
|
1209
|
+
const envMask = new EnvDisplayMask(resolutionCollector.EnvFields);
|
|
1144
1210
|
let resolvedPrimaryKey;
|
|
1145
1211
|
if (record.primaryKey && Object.keys(record.primaryKey).length > 0) {
|
|
1146
|
-
resolvedPrimaryKey =
|
|
1147
|
-
for (const [pkField, pkValue] of Object.entries(record.primaryKey)) {
|
|
1148
|
-
try {
|
|
1149
|
-
resolvedPrimaryKey[pkField] = await this.syncEngine.processFieldValue(pkValue, entityDir, parentEntity, null, // rootRecord
|
|
1150
|
-
0, batchContext, resolutionCollector, pkField, recordProvider);
|
|
1151
|
-
}
|
|
1152
|
-
catch (pkError) {
|
|
1153
|
-
// Check if this is a deferrable lookup error
|
|
1154
|
-
if (pkError instanceof DeferrableLookupError) {
|
|
1155
|
-
throw new Error(`Cannot defer lookup in primaryKey field '${pkField}': ${pkError.message}. Primary key lookups must resolve immediately.`);
|
|
1156
|
-
}
|
|
1157
|
-
throw pkError;
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1212
|
+
resolvedPrimaryKey = await this.resolvePrimaryKey(record.primaryKey, entityDir, parentEntity, batchContext, resolutionCollector, recordProvider);
|
|
1160
1213
|
}
|
|
1161
1214
|
let exists = false;
|
|
1162
1215
|
let isNew = false;
|
|
@@ -1176,7 +1229,7 @@ export class PushService {
|
|
|
1176
1229
|
// Record doesn't exist in database
|
|
1177
1230
|
const autoCreate = this.syncConfig?.push?.autoCreateMissingRecords ?? false;
|
|
1178
1231
|
const pkDisplay = Object.entries(resolvedPrimaryKey)
|
|
1179
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
1232
|
+
.map(([key, value]) => `${key}=${envMask.NewValue(key, String(value))}`)
|
|
1180
1233
|
.join(', ');
|
|
1181
1234
|
if (!autoCreate) {
|
|
1182
1235
|
const warning = `Record not found: ${entityName} with primaryKey {${pkDisplay}}. To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`;
|
|
@@ -1212,11 +1265,32 @@ export class PushService {
|
|
|
1212
1265
|
}
|
|
1213
1266
|
// Apply defaults if entityConfig is provided
|
|
1214
1267
|
if (entityConfig) {
|
|
1215
|
-
const
|
|
1268
|
+
const defaultsCollector = { notes: [], fieldPrefix: 'fields', EnvFields: new Map() };
|
|
1269
|
+
// Folder defaults come from the record file's folders
|
|
1270
|
+
const recordFile = flattenedRecord.FilePath ?? path.join(entityDir, '.record.json');
|
|
1271
|
+
const skippedDefaults = new Map();
|
|
1272
|
+
const defaults = await this.syncEngine.buildDefaults(recordFile, entityConfig, defaultsCollector, skippedDefaults);
|
|
1273
|
+
// Field names match the entity's fields without case; a default for a virtual property applies when the entity has that property
|
|
1274
|
+
const recordFields = new RecordFieldNames(record.fields);
|
|
1275
|
+
// A default left out for an unset optional variable warns when the record would have used it
|
|
1276
|
+
for (const [field, variableName] of skippedDefaults) {
|
|
1277
|
+
const name = EntityFieldName(entity, field) ?? (field in entity ? field : undefined);
|
|
1278
|
+
if (name && !recordFields.Has(name)) {
|
|
1279
|
+
const warning = OptionalEnvSkipMessage(entityName, name, variableName);
|
|
1280
|
+
localWarnings.push(warning);
|
|
1281
|
+
callbacks?.onWarn?.(`⚠️ ${warning}`);
|
|
1282
|
+
AddSkippedOptionalEnvField(resolutionCollector.EnvFields, name, variableName);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1216
1285
|
// Apply defaults only to fields not explicitly set in record.fields
|
|
1217
1286
|
for (const [field, value] of Object.entries(defaults)) {
|
|
1218
|
-
|
|
1219
|
-
|
|
1287
|
+
const name = EntityFieldName(entity, field) ?? (field in entity ? field : undefined);
|
|
1288
|
+
if (name && !recordFields.Has(name)) {
|
|
1289
|
+
entity.Set(name, value);
|
|
1290
|
+
const variables = defaultsCollector.EnvFields?.get(field);
|
|
1291
|
+
if (variables) {
|
|
1292
|
+
resolutionCollector.EnvFields?.set(name, variables);
|
|
1293
|
+
}
|
|
1220
1294
|
}
|
|
1221
1295
|
}
|
|
1222
1296
|
}
|
|
@@ -1277,6 +1351,16 @@ export class PushService {
|
|
|
1277
1351
|
}
|
|
1278
1352
|
}
|
|
1279
1353
|
catch (fieldError) {
|
|
1354
|
+
if (fieldError instanceof EnvVariableNotSetError) {
|
|
1355
|
+
throw fieldError.ForField(`fields.${fieldName}`);
|
|
1356
|
+
}
|
|
1357
|
+
if (fieldError instanceof OptionalEnvMissingError) {
|
|
1358
|
+
const warning = OptionalEnvSkipMessage(entityName, fieldName, fieldError.VariableName);
|
|
1359
|
+
localWarnings.push(warning);
|
|
1360
|
+
callbacks?.onWarn?.(`⚠️ ${warning}`);
|
|
1361
|
+
AddSkippedOptionalEnvField(resolutionCollector.EnvFields, EntityFieldName(entity, fieldName) ?? fieldName, fieldError.VariableName);
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1280
1364
|
// Check if this is a deferrable lookup error first
|
|
1281
1365
|
if (fieldError instanceof DeferrableLookupError) {
|
|
1282
1366
|
// If allowDefer is false, we're in deferred processing mode - can't defer again
|
|
@@ -1376,15 +1460,16 @@ export class PushService {
|
|
|
1376
1460
|
const primaryKeyDisplay = [];
|
|
1377
1461
|
if (entityInfo) {
|
|
1378
1462
|
for (const pk of entityInfo.PrimaryKeys) {
|
|
1379
|
-
primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
|
|
1463
|
+
primaryKeyDisplay.push(`${pk.Name}: ${envMask.NewValue(pk.Name, String(entity.Get(pk.Name)))}`);
|
|
1380
1464
|
}
|
|
1381
1465
|
}
|
|
1382
1466
|
const fieldDiffs = changeKeys.map((fieldName) => {
|
|
1383
1467
|
const field = entity.GetFieldByName(fieldName);
|
|
1468
|
+
const envName = field?.Name ?? fieldName;
|
|
1384
1469
|
return {
|
|
1385
1470
|
field: fieldName,
|
|
1386
|
-
oldValue: this.formatFieldValue(field ? field.OldValue : undefined),
|
|
1387
|
-
newValue: this.formatFieldValue(changes[fieldName]),
|
|
1471
|
+
oldValue: envMask.OldValue(envName, this.formatFieldValue(field ? field.OldValue : undefined)),
|
|
1472
|
+
newValue: envMask.NewValue(envName, this.formatFieldValue(changes[fieldName])),
|
|
1388
1473
|
};
|
|
1389
1474
|
});
|
|
1390
1475
|
this.changeDetails.push({
|
|
@@ -1424,16 +1509,34 @@ export class PushService {
|
|
|
1424
1509
|
const batchContextEntry = { key: lookupKey, entity };
|
|
1425
1510
|
if (exists) {
|
|
1426
1511
|
callbacks?.onLog?.(`[DRY RUN] Would update ${entityName} record`);
|
|
1427
|
-
return { status: 'updated', batchContextEntry };
|
|
1512
|
+
return { status: 'updated', batchContextEntry, warnings: localWarnings.length > 0 ? localWarnings : undefined };
|
|
1428
1513
|
}
|
|
1429
1514
|
else {
|
|
1430
1515
|
callbacks?.onLog?.(`[DRY RUN] Would create ${entityName} record`);
|
|
1431
|
-
|
|
1516
|
+
// A real push records the create after Save; a dry run records it here so the
|
|
1517
|
+
// recap counts it. Only a key the record declares is shown: NewRecord() assigns
|
|
1518
|
+
// a throwaway UUID that changes every run and is never the one a push writes.
|
|
1519
|
+
const entityInfo = this.syncEngine.getEntityInfo(entityName);
|
|
1520
|
+
const primaryKeyDisplay = (entityInfo?.PrimaryKeys ?? [])
|
|
1521
|
+
.filter((pk) => KeyFieldValue(resolvedPrimaryKey, pk.Name) != null || KeyFieldValue(record.fields, pk.Name) != null)
|
|
1522
|
+
.map((pk) => `${pk.Name}: ${envMask.NewValue(pk.Name, String(KeyFieldValue(resolvedPrimaryKey, pk.Name) ?? entity.Get(pk.Name)))}`);
|
|
1523
|
+
this.changeDetails.push({
|
|
1524
|
+
entityName,
|
|
1525
|
+
primaryKey: primaryKeyDisplay.length > 0 ? primaryKeyDisplay.join(', ') : 'new',
|
|
1526
|
+
Operation: 'created',
|
|
1527
|
+
fields: [],
|
|
1528
|
+
});
|
|
1529
|
+
return { status: 'created', batchContextEntry, warnings: localWarnings.length > 0 ? localWarnings : undefined };
|
|
1432
1530
|
}
|
|
1433
1531
|
}
|
|
1532
|
+
this.registerEnvSqlFields(entity, resolutionCollector.EnvFields, isNew || alwaysPush);
|
|
1533
|
+
this.registerPendingEnvSqlChildFields(resolutionCollector);
|
|
1434
1534
|
// Save the record with detailed error logging
|
|
1535
|
+
const keyBeforeSave = new Map(entity.PrimaryKeys.map((pk) => [pk.Name, entity.Get(pk.Name)]));
|
|
1435
1536
|
const recordName = entity.Get('Name');
|
|
1436
|
-
const
|
|
1537
|
+
const recordKeyText = this.recordIdLabel(entity, envMask);
|
|
1538
|
+
// Free error text can hold a value of any variable push resolved, composition children included
|
|
1539
|
+
const errorTextMask = EnvDisplayMask.ForVariables(this.syncEngine.ResolvedEnvVariables);
|
|
1437
1540
|
let saveResult;
|
|
1438
1541
|
try {
|
|
1439
1542
|
// Skip embedding generation during sync — vectors can be computed later by the
|
|
@@ -1453,10 +1556,10 @@ export class PushService {
|
|
|
1453
1556
|
callbacks?.onLog?.(msg);
|
|
1454
1557
|
};
|
|
1455
1558
|
logError(`\n❌ SAVE EXCEPTION for ${entityName}`);
|
|
1456
|
-
logError(` Record ID: ${
|
|
1457
|
-
logError(` Record Name: ${recordName
|
|
1559
|
+
logError(` Record ID: ${recordKeyText}`);
|
|
1560
|
+
logError(` Record Name: ${recordName ? envMask.NewValue('Name', String(recordName)) : 'N/A'}`);
|
|
1458
1561
|
logError(` File Path: ${flattenedRecord.path}`);
|
|
1459
|
-
logError(` Error: ${saveError.message || saveError}`);
|
|
1562
|
+
logError(` Error: ${errorTextMask.MaskText(String(saveError.message || saveError))}`);
|
|
1460
1563
|
// Check for specific error patterns
|
|
1461
1564
|
if (saveError.message?.includes('Cannot insert the value NULL')) {
|
|
1462
1565
|
logError(` Tip: A required field is NULL. Check the entity's required fields.`);
|
|
@@ -1478,11 +1581,11 @@ export class PushService {
|
|
|
1478
1581
|
const displayValue = typeof value === 'string' && value.length > 100
|
|
1479
1582
|
? value.substring(0, 100) + '...'
|
|
1480
1583
|
: value;
|
|
1481
|
-
logError(` ${field.Name}: ${displayValue}`);
|
|
1584
|
+
logError(` ${field.Name}: ${envMask.NewValue(field.Name, String(displayValue))}`);
|
|
1482
1585
|
}
|
|
1483
1586
|
}
|
|
1484
1587
|
logError(''); // Empty line for readability
|
|
1485
|
-
throw saveError;
|
|
1588
|
+
throw MaskedError(saveError, errorTextMask);
|
|
1486
1589
|
}
|
|
1487
1590
|
if (!saveResult) {
|
|
1488
1591
|
// Helper to log to both console and callbacks
|
|
@@ -1491,14 +1594,14 @@ export class PushService {
|
|
|
1491
1594
|
callbacks?.onLog?.(msg);
|
|
1492
1595
|
};
|
|
1493
1596
|
logError(`\n❌ SAVE RETURNED FALSE for ${entityName}`);
|
|
1494
|
-
logError(` Record ID: ${
|
|
1495
|
-
logError(` Record Name: ${recordName
|
|
1597
|
+
logError(` Record ID: ${recordKeyText}`);
|
|
1598
|
+
logError(` Record Name: ${recordName ? envMask.NewValue('Name', String(recordName)) : 'N/A'}`);
|
|
1496
1599
|
logError(` File Path: ${flattenedRecord.path}`);
|
|
1497
1600
|
// Log the LatestResult for debugging
|
|
1498
1601
|
if (entity.LatestResult) {
|
|
1499
1602
|
const completeMessage = entity.LatestResult.CompleteMessage;
|
|
1500
1603
|
if (completeMessage) {
|
|
1501
|
-
logError(` Database Error: ${completeMessage}`);
|
|
1604
|
+
logError(` Database Error: ${errorTextMask.MaskText(completeMessage)}`);
|
|
1502
1605
|
}
|
|
1503
1606
|
if (entity.LatestResult.SQL) {
|
|
1504
1607
|
// Don't log the full SQL as it might be huge, just indicate it's available
|
|
@@ -1513,7 +1616,7 @@ export class PushService {
|
|
|
1513
1616
|
const displayValue = typeof value === 'string' && value.length > 100
|
|
1514
1617
|
? value.substring(0, 100) + '...'
|
|
1515
1618
|
: value;
|
|
1516
|
-
logError(` ${field.Name}: ${displayValue}`);
|
|
1619
|
+
logError(` ${field.Name}: ${envMask.NewValue(field.Name, String(displayValue))}`);
|
|
1517
1620
|
}
|
|
1518
1621
|
}
|
|
1519
1622
|
logError(''); // Empty line for readability
|
|
@@ -1525,17 +1628,18 @@ export class PushService {
|
|
|
1525
1628
|
if (entityInfo) {
|
|
1526
1629
|
for (const pk of entityInfo.PrimaryKeys) {
|
|
1527
1630
|
const pkValue = entity.Get(pk.Name);
|
|
1528
|
-
primaryKeyInfo.push(`${pk.Name}=${this.formatFieldValue(pkValue)}`);
|
|
1631
|
+
primaryKeyInfo.push(`${pk.Name}=${envMask.NewValue(pk.Name, this.formatFieldValue(pkValue))}`);
|
|
1529
1632
|
}
|
|
1530
1633
|
}
|
|
1531
1634
|
// Collect field values that were being saved
|
|
1532
1635
|
for (const [fieldName, fieldValue] of Object.entries(record.fields)) {
|
|
1533
1636
|
const processedValue = entity.Get(fieldName);
|
|
1534
|
-
|
|
1637
|
+
const envName = entity.GetFieldByName(fieldName)?.Name ?? fieldName;
|
|
1638
|
+
fieldInfo.push(`${fieldName}=${envMask.NewValue(envName, this.formatFieldValue(processedValue))}`);
|
|
1535
1639
|
}
|
|
1536
1640
|
// Get the actual error details from the entity
|
|
1537
|
-
const errorMessage = entity.LatestResult?.CompleteMessage || 'Unknown error';
|
|
1538
|
-
const errorDetails = entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '';
|
|
1641
|
+
const errorMessage = errorTextMask.MaskText(entity.LatestResult?.CompleteMessage || 'Unknown error');
|
|
1642
|
+
const errorDetails = errorTextMask.MaskText(entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '');
|
|
1539
1643
|
// Log detailed error information
|
|
1540
1644
|
callbacks?.onError?.(`\n❌ FATAL ERROR: Failed to save ${entityName} record`);
|
|
1541
1645
|
callbacks?.onError?.(` Entity: ${entityName}`);
|
|
@@ -1584,7 +1688,8 @@ export class PushService {
|
|
|
1584
1688
|
deferredRecord = {
|
|
1585
1689
|
flattenedRecord,
|
|
1586
1690
|
entityDir,
|
|
1587
|
-
entityConfig
|
|
1691
|
+
entityConfig,
|
|
1692
|
+
batchContext
|
|
1588
1693
|
};
|
|
1589
1694
|
if (options.verbose) {
|
|
1590
1695
|
callbacks?.onLog?.(` 📋 Queued ${entityName} for deferred processing (record saved, some fields pending)`);
|
|
@@ -1598,9 +1703,10 @@ export class PushService {
|
|
|
1598
1703
|
if (entityInfo) {
|
|
1599
1704
|
const newPrimaryKey = {};
|
|
1600
1705
|
const primaryKeyDisplay = [];
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1706
|
+
// An env-sourced key keeps the file's reference unless the database assigned another value
|
|
1707
|
+
for (const key of CreatedRecordKeyFields(entityInfo.PrimaryKeys, entity, keyBeforeSave, record.primaryKey, originalFields, envMask)) {
|
|
1708
|
+
newPrimaryKey[key.Name] = key.FileValue;
|
|
1709
|
+
primaryKeyDisplay.push(`${key.Name}: ${key.KeptBySave ? envMask.NewValue(key.Name, String(key.SavedValue)) : String(key.SavedValue)}`);
|
|
1604
1710
|
}
|
|
1605
1711
|
record.primaryKey = newPrimaryKey;
|
|
1606
1712
|
// Capture the create for the changes recap (no field diffs — the whole record is new).
|
|
@@ -1618,6 +1724,12 @@ export class PushService {
|
|
|
1618
1724
|
if (!shouldWriteSync) {
|
|
1619
1725
|
delete record.sync;
|
|
1620
1726
|
}
|
|
1727
|
+
else if (deferredRecord) {
|
|
1728
|
+
// A deferred record keeps its earlier checksum in the file, so each later --incremental push checks it again until the record changes
|
|
1729
|
+
if (options.verbose) {
|
|
1730
|
+
callbacks?.onLog?.(` - Sync metadata waits for the deferred fields`);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1621
1733
|
else if (isNew || isDirty) {
|
|
1622
1734
|
const checksumPayload = this.buildRecordChecksumPayload(record, originalFields);
|
|
1623
1735
|
const checksum = await this.syncEngine.calculateChecksumWithFileContent(checksumPayload, entityDir);
|
|
@@ -1682,6 +1794,77 @@ export class PushService {
|
|
|
1682
1794
|
status: isNew ? 'created' : (isDirty ? 'updated' : 'unchanged'),
|
|
1683
1795
|
};
|
|
1684
1796
|
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Resolves each primary-key value (`@lookup`, `@parent`, `@env`, …). A key lookup cannot be deferred.
|
|
1799
|
+
* With `referencesOnly`, only `@env:`, `@parent:` and `@owner:` values are resolved; others are copied as is.
|
|
1800
|
+
*/
|
|
1801
|
+
async resolvePrimaryKey(primaryKey, entityDir, parentEntity, batchContext, resolutionCollector, recordProvider, referencesOnly = false) {
|
|
1802
|
+
const resolved = {};
|
|
1803
|
+
for (const [pkField, pkValue] of Object.entries(primaryKey)) {
|
|
1804
|
+
if (referencesOnly && !IsKeyReference(pkValue)) {
|
|
1805
|
+
resolved[pkField] = pkValue;
|
|
1806
|
+
continue;
|
|
1807
|
+
}
|
|
1808
|
+
try {
|
|
1809
|
+
resolved[pkField] = await this.syncEngine.processFieldValue(pkValue, entityDir, parentEntity, null, // rootRecord
|
|
1810
|
+
0, batchContext, resolutionCollector, pkField, recordProvider);
|
|
1811
|
+
}
|
|
1812
|
+
catch (pkError) {
|
|
1813
|
+
if (pkError instanceof EnvVariableNotSetError) {
|
|
1814
|
+
throw pkError.ForField(`primaryKey.${pkField}`);
|
|
1815
|
+
}
|
|
1816
|
+
if (pkError instanceof DeferrableLookupError) {
|
|
1817
|
+
throw new Error(`Cannot defer lookup in primaryKey field '${pkField}': ${pkError.message}. Primary key lookups must resolve immediately.`);
|
|
1818
|
+
}
|
|
1819
|
+
throw pkError;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return resolved;
|
|
1823
|
+
}
|
|
1824
|
+
/** A file key value for printing: `<from @env:VAR>` for an `@env:` reference, else the value. */
|
|
1825
|
+
fileKeyText(value) {
|
|
1826
|
+
const reference = ParseEnvReference(value);
|
|
1827
|
+
return reference ? EnvSourceLabel([reference.VariableName]) : String(value);
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* The key with each `@env:` value resolved through `collector`, which records the env-sourced fields.
|
|
1831
|
+
* Other values are copied as written. A malformed or unset reference throws a names-only error.
|
|
1832
|
+
*/
|
|
1833
|
+
async resolveEnvKeyValues(primaryKey, entityDir, collector) {
|
|
1834
|
+
const resolved = {};
|
|
1835
|
+
for (const [field, value] of Object.entries(primaryKey)) {
|
|
1836
|
+
resolved[field] = typeof value === 'string' && value.startsWith(METADATA_KEYWORDS.ENV)
|
|
1837
|
+
? await this.syncEngine.processFieldValue(value, entityDir, null, null, 0, undefined, collector, field)
|
|
1838
|
+
: value;
|
|
1839
|
+
}
|
|
1840
|
+
return resolved;
|
|
1841
|
+
}
|
|
1842
|
+
/** True when the child holds every key value; UUID key fields match without regard to case. */
|
|
1843
|
+
childHasKey(child, key) {
|
|
1844
|
+
return Object.entries(key).every(([field, value]) => {
|
|
1845
|
+
const current = child.Get(field);
|
|
1846
|
+
const isUuidField = child.GetFieldByName(field)?.EntityFieldInfo?.IsUniqueIdentifier === true;
|
|
1847
|
+
if (isUuidField && typeof current === 'string' && typeof value === 'string') {
|
|
1848
|
+
return UUIDsEqual(current, value);
|
|
1849
|
+
}
|
|
1850
|
+
return String(current) === String(value);
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* The record's key for save-failure output: the value of a single key field, or `Field=value` pairs
|
|
1855
|
+
* for a composite key, with env-sourced key fields shown as labels. `NEW` when no key field has a value.
|
|
1856
|
+
*/
|
|
1857
|
+
recordIdLabel(entity, envMask) {
|
|
1858
|
+
const keyFields = entity.PrimaryKeys.filter((pk) => {
|
|
1859
|
+
const value = entity.Get(pk.Name);
|
|
1860
|
+
return value !== null && value !== undefined && value !== '';
|
|
1861
|
+
});
|
|
1862
|
+
if (keyFields.length === 0) {
|
|
1863
|
+
return 'NEW';
|
|
1864
|
+
}
|
|
1865
|
+
const labels = keyFields.map((pk) => envMask.NewValue(pk.Name, String(entity.Get(pk.Name))));
|
|
1866
|
+
return keyFields.length === 1 ? labels[0] : keyFields.map((pk, i) => `${pk.Name}=${labels[i]}`).join(', ');
|
|
1867
|
+
}
|
|
1685
1868
|
formatFieldValue(value) {
|
|
1686
1869
|
if (value === null || value === undefined)
|
|
1687
1870
|
return 'null';
|
|
@@ -1698,14 +1881,17 @@ export class PushService {
|
|
|
1698
1881
|
}
|
|
1699
1882
|
return String(value);
|
|
1700
1883
|
}
|
|
1701
|
-
async processDeleteRecord(flattenedRecord,
|
|
1884
|
+
async processDeleteRecord(flattenedRecord, entityDir, options, callbacks, isDbOnly = false) {
|
|
1702
1885
|
const { record, entityName } = flattenedRecord;
|
|
1703
1886
|
// Validate that we have a primary key for deletion
|
|
1704
1887
|
if (!record.primaryKey || Object.keys(record.primaryKey).length === 0) {
|
|
1705
1888
|
throw new Error(`Cannot delete ${entityName} record without primaryKey. Please specify primaryKey fields.`);
|
|
1706
1889
|
}
|
|
1707
|
-
// Load the entity to check if it exists in the target database
|
|
1708
|
-
const
|
|
1890
|
+
// Load the entity to check if it exists in the target database; an `@env:` key value is resolved first
|
|
1891
|
+
const keyCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map() };
|
|
1892
|
+
const resolvedKey = await this.resolveEnvKeyValues(record.primaryKey, entityDir, keyCollector);
|
|
1893
|
+
const keyMask = new EnvDisplayMask(keyCollector.EnvFields);
|
|
1894
|
+
const existingEntity = await this.syncEngine.loadEntity(entityName, resolvedKey);
|
|
1709
1895
|
// Check if the deletion has already been processed
|
|
1710
1896
|
if (record.deleteRecord?.deletedAt) {
|
|
1711
1897
|
// Verify if record still exists in THIS database
|
|
@@ -1724,7 +1910,7 @@ export class PushService {
|
|
|
1724
1910
|
}
|
|
1725
1911
|
if (!existingEntity) {
|
|
1726
1912
|
const pkDisplay = Object.entries(record.primaryKey)
|
|
1727
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
1913
|
+
.map(([key, value]) => `${key}=${this.fileKeyText(value)}`)
|
|
1728
1914
|
.join(', ');
|
|
1729
1915
|
const warning = `Record not found for deletion: ${entityName} with primaryKey {${pkDisplay}}`;
|
|
1730
1916
|
this.warnings.push(warning);
|
|
@@ -1737,12 +1923,14 @@ export class PushService {
|
|
|
1737
1923
|
record.deleteRecord.notFound = true;
|
|
1738
1924
|
return { status: 'skipped', isDuplicate: false };
|
|
1739
1925
|
}
|
|
1926
|
+
// Key values and free error text can hold a value of any variable push resolved
|
|
1927
|
+
const errorTextMask = EnvDisplayMask.ForVariables(this.syncEngine.ResolvedEnvVariables);
|
|
1740
1928
|
// Log the deletion
|
|
1741
1929
|
const entityInfo = this.syncEngine.getEntityInfo(entityName);
|
|
1742
1930
|
const primaryKeyDisplay = [];
|
|
1743
1931
|
if (entityInfo) {
|
|
1744
1932
|
for (const pk of entityInfo.PrimaryKeys) {
|
|
1745
|
-
primaryKeyDisplay.push(`${pk.Name}: ${existingEntity.Get(pk.Name)}`);
|
|
1933
|
+
primaryKeyDisplay.push(`${pk.Name}: ${keyMask.NewValue(pk.Name, errorTextMask.MaskText(String(existingEntity.Get(pk.Name))))}`);
|
|
1746
1934
|
}
|
|
1747
1935
|
}
|
|
1748
1936
|
// Deletion detail streams inline only in verbose mode — the recap is the default
|
|
@@ -1759,7 +1947,9 @@ export class PushService {
|
|
|
1759
1947
|
}
|
|
1760
1948
|
const recordNameVerbose = existingEntity.Get('Name');
|
|
1761
1949
|
if (recordNameVerbose) {
|
|
1762
|
-
|
|
1950
|
+
const fileNameKey = Object.keys(record.fields ?? {}).find((key) => key.toLowerCase() === 'name');
|
|
1951
|
+
const nameFromEnv = fileNameKey !== undefined && ParseEnvReference(record.fields?.[fileNameKey]) !== null;
|
|
1952
|
+
callbacks?.onLog?.(` Name: ${nameFromEnv ? ENV_HIDDEN_VALUE : recordNameVerbose}`);
|
|
1763
1953
|
}
|
|
1764
1954
|
}
|
|
1765
1955
|
// Capture the deletion for the changes recap (no field diffs for deletes).
|
|
@@ -1775,13 +1965,15 @@ export class PushService {
|
|
|
1775
1965
|
}
|
|
1776
1966
|
return { status: 'deleted', isDuplicate: false };
|
|
1777
1967
|
}
|
|
1968
|
+
// An env-sourced delete key gets the names-only SQL log warning
|
|
1969
|
+
this.registerEnvSqlFields(existingEntity, keyCollector.EnvFields, false);
|
|
1778
1970
|
// Delete the record
|
|
1779
1971
|
try {
|
|
1780
1972
|
const deleteResult = await existingEntity.Delete();
|
|
1781
1973
|
if (!deleteResult) {
|
|
1782
1974
|
// Check the LatestResult for error details
|
|
1783
|
-
const errorMessage = existingEntity.LatestResult?.CompleteMessage || 'Unknown error';
|
|
1784
|
-
const errorDetails = existingEntity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '';
|
|
1975
|
+
const errorMessage = errorTextMask.MaskText(existingEntity.LatestResult?.CompleteMessage || 'Unknown error');
|
|
1976
|
+
const errorDetails = errorTextMask.MaskText(existingEntity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '');
|
|
1785
1977
|
callbacks?.onError?.(`\n❌ Failed to delete ${entityName} record`);
|
|
1786
1978
|
callbacks?.onError?.(` Primary Key: {${primaryKeyDisplay.join(', ')}}`);
|
|
1787
1979
|
callbacks?.onError?.(` Error: ${errorMessage}`);
|
|
@@ -1797,7 +1989,7 @@ export class PushService {
|
|
|
1797
1989
|
throw new Error(`Failed to delete ${entityName} record: ${errorMessage}`);
|
|
1798
1990
|
}
|
|
1799
1991
|
// Remove from metadata cache
|
|
1800
|
-
this.syncMetadataEngine.removeEntityFromCache(entityName,
|
|
1992
|
+
this.syncMetadataEngine.removeEntityFromCache(entityName, resolvedKey);
|
|
1801
1993
|
// Set deletedAt timestamp after successful deletion (only for metadata records)
|
|
1802
1994
|
if (!isDbOnly) {
|
|
1803
1995
|
if (!record.deleteRecord) {
|
|
@@ -1820,7 +2012,7 @@ export class PushService {
|
|
|
1820
2012
|
catch (deleteError) {
|
|
1821
2013
|
console.error(`\n❌ DELETE EXCEPTION for ${entityName}`);
|
|
1822
2014
|
console.error(` Primary Key: {${primaryKeyDisplay.join(', ')}}`);
|
|
1823
|
-
console.error(` Error: ${deleteError.message || deleteError}`);
|
|
2015
|
+
console.error(` Error: ${errorTextMask.MaskText(String(deleteError.message || deleteError))}`);
|
|
1824
2016
|
// Check for specific error patterns
|
|
1825
2017
|
if (deleteError.message?.includes('FOREIGN KEY constraint')) {
|
|
1826
2018
|
console.error(` Tip: This record is referenced by other records and cannot be deleted.`);
|
|
@@ -1829,7 +2021,7 @@ export class PushService {
|
|
|
1829
2021
|
else if (deleteError.message?.includes('permission')) {
|
|
1830
2022
|
console.error(` Tip: You may not have permission to delete this record.`);
|
|
1831
2023
|
}
|
|
1832
|
-
throw deleteError;
|
|
2024
|
+
throw MaskedError(deleteError, errorTextMask);
|
|
1833
2025
|
}
|
|
1834
2026
|
}
|
|
1835
2027
|
/**
|
|
@@ -1923,7 +2115,7 @@ export class PushService {
|
|
|
1923
2115
|
}
|
|
1924
2116
|
}
|
|
1925
2117
|
}
|
|
1926
|
-
const pattern = entityConfig.filePattern ||
|
|
2118
|
+
const pattern = entityConfig.filePattern || DEFAULT_RECORD_FILE_PATTERN;
|
|
1927
2119
|
const files = await fastGlob(pattern, {
|
|
1928
2120
|
cwd: entityDir,
|
|
1929
2121
|
absolute: true,
|
|
@@ -1974,7 +2166,7 @@ export class PushService {
|
|
|
1974
2166
|
continue;
|
|
1975
2167
|
}
|
|
1976
2168
|
// Find all JSON files
|
|
1977
|
-
const pattern = entityConfig.filePattern ||
|
|
2169
|
+
const pattern = entityConfig.filePattern || DEFAULT_RECORD_FILE_PATTERN;
|
|
1978
2170
|
const files = await fastGlob(pattern, {
|
|
1979
2171
|
cwd: entityDir,
|
|
1980
2172
|
absolute: true,
|
|
@@ -1997,6 +2189,9 @@ export class PushService {
|
|
|
1997
2189
|
const records = Array.isArray(fileData) ? fileData : [fileData];
|
|
1998
2190
|
// Phase 1: Flatten only - accumulates into analyzer's internal state
|
|
1999
2191
|
const flattenedRecords = analyzer.flattenFileRecords(records, entityConfig.entity);
|
|
2192
|
+
for (const flattened of flattenedRecords) {
|
|
2193
|
+
flattened.FilePath = filePath;
|
|
2194
|
+
}
|
|
2000
2195
|
allFlattenedRecords.push(...flattenedRecords);
|
|
2001
2196
|
}
|
|
2002
2197
|
catch (error) {
|
|
@@ -2106,9 +2301,10 @@ export class PushService {
|
|
|
2106
2301
|
}
|
|
2107
2302
|
}
|
|
2108
2303
|
catch (error) {
|
|
2109
|
-
|
|
2304
|
+
const located = error instanceof Error ? this.locateRecordError(error, record) : error;
|
|
2305
|
+
callbacks?.onError?.(` Failed to delete ${record.entityName}: ${located}`);
|
|
2110
2306
|
errors++;
|
|
2111
|
-
throw
|
|
2307
|
+
throw located; // Fail fast on first deletion error
|
|
2112
2308
|
}
|
|
2113
2309
|
}
|
|
2114
2310
|
}
|
|
@@ -2160,14 +2356,14 @@ export class PushService {
|
|
|
2160
2356
|
let created = 0;
|
|
2161
2357
|
let updated = 0;
|
|
2162
2358
|
let errors = 0;
|
|
2163
|
-
//
|
|
2164
|
-
|
|
2165
|
-
const batchContext = new BatchContextIndex();
|
|
2359
|
+
// A deferred record without the batch context of its main pass gets one for this phase
|
|
2360
|
+
const phaseBatchContext = new BatchContextIndex();
|
|
2166
2361
|
for (const deferred of this.deferredRecords) {
|
|
2167
2362
|
const { flattenedRecord, entityDir, entityConfig } = deferred;
|
|
2363
|
+
const batchContext = deferred.batchContext ?? phaseBatchContext;
|
|
2168
2364
|
const entityName = flattenedRecord.entityName;
|
|
2169
2365
|
const recordId = flattenedRecord.record.primaryKey
|
|
2170
|
-
? Object.entries(flattenedRecord.record.primaryKey).map(([k, v]) => `${k}=${v}`).join(', ')
|
|
2366
|
+
? Object.entries(flattenedRecord.record.primaryKey).map(([k, v]) => `${k}=${this.fileKeyText(v)}`).join(', ')
|
|
2171
2367
|
: (flattenedRecord.record.fields.Name || 'NEW');
|
|
2172
2368
|
try {
|
|
2173
2369
|
// Re-run processFlattenedRecord with allowDefer=false
|
|
@@ -2195,7 +2391,7 @@ export class PushService {
|
|
|
2195
2391
|
}
|
|
2196
2392
|
}
|
|
2197
2393
|
catch (error) {
|
|
2198
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
2394
|
+
const err = this.locateRecordError(error instanceof Error ? error : new Error(String(error)), flattenedRecord);
|
|
2199
2395
|
callbacks?.onError?.(` ✗ Failed to process deferred record: ${entityName} (${recordId})`);
|
|
2200
2396
|
callbacks?.onError?.(` Error: ${err.message}`);
|
|
2201
2397
|
callbacks?.onError?.(` Tip: Ensure all referenced records exist or remove the ?allowDefer flag`);
|
|
@@ -2346,6 +2542,86 @@ export class PushService {
|
|
|
2346
2542
|
buildRecordChecksumPayload(record, fieldsOverride) {
|
|
2347
2543
|
return SyncEngine.BuildRecordChecksumPayload(fieldsOverride ?? record.fields, record.collections, record.embeds, record.extension);
|
|
2348
2544
|
}
|
|
2545
|
+
/**
|
|
2546
|
+
* Resolves one field of an embed, collection item or extension and sets it on the target.
|
|
2547
|
+
* A missing `@env:VAR?optional` variable leaves the field unset and raises a warning.
|
|
2548
|
+
* `sharesOwnerFields` (IS-A extension children) records env-sourced fields in the owner's collector.
|
|
2549
|
+
*/
|
|
2550
|
+
async setCompositionField(target, fieldName, rawValue, entityDir, batchContext, resolutionCollector, recordProvider, ownerRecord, callbacks, sharesOwnerFields = false) {
|
|
2551
|
+
// An IS-A extension child writes owner fields, so it records into the owner's collector. Other
|
|
2552
|
+
// children get their own EnvFields map, so a child field never masks an owner field of the same name.
|
|
2553
|
+
const childCollector = sharesOwnerFields
|
|
2554
|
+
? resolutionCollector
|
|
2555
|
+
: { notes: resolutionCollector.notes, fieldPrefix: resolutionCollector.fieldPrefix, EnvFields: new Map() };
|
|
2556
|
+
try {
|
|
2557
|
+
const value = await this.syncEngine.processFieldValue(rawValue, entityDir, null, null, 0, batchContext, childCollector, fieldName, recordProvider, ownerRecord);
|
|
2558
|
+
target.Set(fieldName, value);
|
|
2559
|
+
const variables = childCollector.EnvFields?.get(fieldName);
|
|
2560
|
+
if (variables) {
|
|
2561
|
+
this.queueEnvSqlChildField(resolutionCollector, { Target: target, FieldName: fieldName, Variables: variables });
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
catch (error) {
|
|
2565
|
+
if (error instanceof EnvVariableNotSetError) {
|
|
2566
|
+
throw error.ForField(`fields.${fieldName}`, target.EntityInfo.Name);
|
|
2567
|
+
}
|
|
2568
|
+
if (!(error instanceof OptionalEnvMissingError)) {
|
|
2569
|
+
throw error;
|
|
2570
|
+
}
|
|
2571
|
+
this.addWarning(OptionalEnvSkipMessage(target.EntityInfo.Name, fieldName, error.VariableName), callbacks);
|
|
2572
|
+
this.queueEnvSqlChildField(resolutionCollector, { Target: target, FieldName: fieldName, Variables: new Set([error.VariableName]) });
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
/** Runs composition work and names its path (and entity) in an unset-variable error. */
|
|
2576
|
+
async withCompositionPath(compositionPath, entityName, work) {
|
|
2577
|
+
try {
|
|
2578
|
+
await work();
|
|
2579
|
+
}
|
|
2580
|
+
catch (error) {
|
|
2581
|
+
if (error instanceof EnvVariableNotSetError) {
|
|
2582
|
+
throw error.Within(compositionPath, entityName());
|
|
2583
|
+
}
|
|
2584
|
+
throw error;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
/** Registers the env-sourced fields of this save with the SQL log redactor, when a log is open. */
|
|
2588
|
+
registerEnvSqlFields(entity, envFields, fullRow) {
|
|
2589
|
+
this.envSqlLog?.Redactor.RegisterEntityFields(entity, envFields, fullRow);
|
|
2590
|
+
}
|
|
2591
|
+
/** Keeps an env-sourced composition child field until its owner's save, when a SQL log is open. */
|
|
2592
|
+
queueEnvSqlChildField(ownerCollector, field) {
|
|
2593
|
+
if (!this.envSqlLog) {
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
const pending = this.pendingEnvSqlChildFields.get(ownerCollector) ?? [];
|
|
2597
|
+
pending.push(field);
|
|
2598
|
+
this.pendingEnvSqlChildFields.set(ownerCollector, pending);
|
|
2599
|
+
}
|
|
2600
|
+
/** Queues each env-sourced key field of a composition child for the names-only SQL log warning. */
|
|
2601
|
+
queueEnvSqlChildKey(ownerCollector, child, keyCollector) {
|
|
2602
|
+
for (const [fieldName, variables] of keyCollector.EnvFields ?? []) {
|
|
2603
|
+
this.queueEnvSqlChildField(ownerCollector, { Target: child, FieldName: fieldName, Variables: variables });
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
/** Registers the queued composition child fields of the record that is about to be saved. */
|
|
2607
|
+
registerPendingEnvSqlChildFields(ownerCollector) {
|
|
2608
|
+
const pending = this.pendingEnvSqlChildFields.get(ownerCollector) ?? [];
|
|
2609
|
+
this.pendingEnvSqlChildFields.delete(ownerCollector);
|
|
2610
|
+
for (const field of pending) {
|
|
2611
|
+
this.registerEnvSqlCompositionField(field.Target, field.FieldName, field.Variables);
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
/** Registers one env-sourced composition child field when its save statement holds it. */
|
|
2615
|
+
registerEnvSqlCompositionField(target, fieldName, variables) {
|
|
2616
|
+
const fullRow = !target.IsSaved || this.syncConfig?.push?.alwaysPush === true;
|
|
2617
|
+
this.registerEnvSqlFields(target, new Map([[fieldName, variables]]), fullRow);
|
|
2618
|
+
}
|
|
2619
|
+
/** Adds the names-only SQL log warnings of this run. */
|
|
2620
|
+
addEnvSqlWarnings(sqlLogPath, callbacks) {
|
|
2621
|
+
for (const warning of this.envSqlLog?.Redactor.Warnings(sqlLogPath) ?? []) {
|
|
2622
|
+
this.addWarning(warning, callbacks);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2349
2625
|
/**
|
|
2350
2626
|
* Applies first-class composition axes onto the entity before Save (§5):
|
|
2351
2627
|
* 1. embeds (peer first via {fkField}_EnsureObject() or companion.Ensure())
|
|
@@ -2392,16 +2668,17 @@ export class PushService {
|
|
|
2392
2668
|
if (!embeddedEntity) {
|
|
2393
2669
|
throw new Error(`Failed to resolve embedded companion for '${fkField}' on entity '${entityName}'. Ensure that DeclareEmbeddedRecord is called for '${fkField}'.`);
|
|
2394
2670
|
}
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
)
|
|
2400
|
-
|
|
2671
|
+
const embedded = embeddedEntity;
|
|
2672
|
+
await this.withCompositionPath(`embeds.${fkField}`, () => embedded.EntityInfo.Name, async () => {
|
|
2673
|
+
// Apply embed fields, passing ownerRecord = entity for @owner: resolution
|
|
2674
|
+
if (embedRecord.fields && typeof embedRecord.fields === 'object') {
|
|
2675
|
+
for (const [fName, fVal] of Object.entries(embedRecord.fields)) {
|
|
2676
|
+
await this.setCompositionField(embedded, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks);
|
|
2677
|
+
}
|
|
2401
2678
|
}
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2679
|
+
// Recursively apply nested composition on the embedded record
|
|
2680
|
+
await this.applyCompositionAxes(embedded, embedRecord, embedded.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
|
|
2681
|
+
});
|
|
2405
2682
|
}
|
|
2406
2683
|
}
|
|
2407
2684
|
// 2. Collections: owner.Lines.Create() (or load existing by PK into the collection), recurse apply on child entity
|
|
@@ -2465,50 +2742,48 @@ export class PushService {
|
|
|
2465
2742
|
const matchedItemSet = new Set();
|
|
2466
2743
|
const colConfig = entityConfig?.collections?.[colName];
|
|
2467
2744
|
const mode = colConfig?.mode ?? 'upsert';
|
|
2468
|
-
for (const itemData of colItems) {
|
|
2745
|
+
for (const [itemIndex, itemData] of colItems.entries()) {
|
|
2469
2746
|
if (!itemData || typeof itemData !== 'object')
|
|
2470
2747
|
continue;
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2748
|
+
await this.withCompositionPath(`collections.${colName}[${itemIndex}]`, () => col.RelatedEntityName, async () => {
|
|
2749
|
+
let targetChild = null;
|
|
2750
|
+
// The child's key with `@env:` values resolved; env-sourced key fields are kept for the SQL log
|
|
2751
|
+
const childKeyCollector = { notes: resolutionCollector.notes, fieldPrefix: 'primaryKey', EnvFields: new Map() };
|
|
2752
|
+
const childKey = itemData.primaryKey
|
|
2753
|
+
? await this.resolveEnvKeyValues(itemData.primaryKey, entityDir, childKeyCollector)
|
|
2754
|
+
: undefined;
|
|
2755
|
+
// Match by primaryKey if available
|
|
2756
|
+
if (childKey && Object.keys(childKey).length > 0 && loadedItems.length > 0) {
|
|
2757
|
+
targetChild = loadedItems.find((child) => this.childHasKey(child, childKey)) ?? null;
|
|
2758
|
+
}
|
|
2759
|
+
if (itemData.deleteRecord?.delete === true) {
|
|
2760
|
+
// Explicit delete in both modes (§8.1(a) rider 1)
|
|
2761
|
+
if (targetChild) {
|
|
2762
|
+
this.queueEnvSqlChildKey(resolutionCollector, targetChild, childKeyCollector);
|
|
2763
|
+
col.Remove(targetChild);
|
|
2764
|
+
matchedItemSet.add(targetChild);
|
|
2479
2765
|
}
|
|
2480
|
-
return
|
|
2481
|
-
}) ?? null;
|
|
2482
|
-
}
|
|
2483
|
-
if (itemData.deleteRecord?.delete === true) {
|
|
2484
|
-
// Explicit delete in both modes (§8.1(a) rider 1)
|
|
2485
|
-
if (targetChild) {
|
|
2486
|
-
col.Remove(targetChild);
|
|
2487
|
-
matchedItemSet.add(targetChild);
|
|
2766
|
+
return;
|
|
2488
2767
|
}
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
targetChild = await col.Create();
|
|
2493
|
-
if (itemData.primaryKey) {
|
|
2494
|
-
for (const [pkK, pkV] of Object.entries(itemData.primaryKey)) {
|
|
2768
|
+
if (!targetChild) {
|
|
2769
|
+
targetChild = await col.Create();
|
|
2770
|
+
for (const [pkK, pkV] of Object.entries(childKey ?? {})) {
|
|
2495
2771
|
targetChild.Set(pkK, pkV);
|
|
2496
2772
|
}
|
|
2497
2773
|
}
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
matchedItemSet.add(targetChild);
|
|
2501
|
-
}
|
|
2502
|
-
// Apply fields with ownerRecord = entity for @owner:Field resolution
|
|
2503
|
-
if (itemData.fields && typeof itemData.fields === 'object') {
|
|
2504
|
-
for (const [fName, fVal] of Object.entries(itemData.fields)) {
|
|
2505
|
-
const processedValue = await this.syncEngine.processFieldValue(fVal, entityDir, null, null, 0, batchContext, resolutionCollector, fName, recordProvider, entity // ownerRecord
|
|
2506
|
-
);
|
|
2507
|
-
targetChild.Set(fName, processedValue);
|
|
2774
|
+
else {
|
|
2775
|
+
matchedItemSet.add(targetChild);
|
|
2508
2776
|
}
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2777
|
+
this.queueEnvSqlChildKey(resolutionCollector, targetChild, childKeyCollector);
|
|
2778
|
+
// Apply fields with ownerRecord = entity for @owner:Field resolution
|
|
2779
|
+
if (itemData.fields && typeof itemData.fields === 'object') {
|
|
2780
|
+
for (const [fName, fVal] of Object.entries(itemData.fields)) {
|
|
2781
|
+
await this.setCompositionField(targetChild, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
// Recursively apply nested composition on the collection item
|
|
2785
|
+
await this.applyCompositionAxes(targetChild, itemData, targetChild.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
|
|
2786
|
+
});
|
|
2512
2787
|
}
|
|
2513
2788
|
// Handle authoritative mode deletions (§8.1(a))
|
|
2514
2789
|
if (mode === 'authoritative' && loadedItems.length > 0) {
|
|
@@ -2557,13 +2832,13 @@ export class PushService {
|
|
|
2557
2832
|
throw new Error(`EnsureISAChild returned null for extension on '${entityName}'${subName ? ` (${subName})` : ''}. ` +
|
|
2558
2833
|
`Ensure that an EntitySubtypeResolver or Entity.SubtypeSelector is configured, or specify 'entity' in extension.`);
|
|
2559
2834
|
}
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2835
|
+
await this.withCompositionPath('extension', () => child.EntityInfo.Name, async () => {
|
|
2836
|
+
for (const [fName, fVal] of Object.entries(extObj.fields)) {
|
|
2837
|
+
await this.setCompositionField(child, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks, true);
|
|
2838
|
+
}
|
|
2839
|
+
// Recursively apply nested composition on the child extension
|
|
2840
|
+
await this.applyCompositionAxes(child, extObj, child.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
|
|
2841
|
+
});
|
|
2567
2842
|
}
|
|
2568
2843
|
else {
|
|
2569
2844
|
// Map form: { [SubtypeName]: { fields: { ... } } }
|
|
@@ -2576,16 +2851,16 @@ export class PushService {
|
|
|
2576
2851
|
if (!child) {
|
|
2577
2852
|
throw new Error(`EnsureISAChild returned null for extension subtype '${subKey}' on '${entityName}'.`);
|
|
2578
2853
|
}
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2854
|
+
await this.withCompositionPath(`extension.${subKey}`, () => child.EntityInfo.Name, async () => {
|
|
2855
|
+
const subRecord = subVal;
|
|
2856
|
+
if (subRecord.fields && typeof subRecord.fields === 'object') {
|
|
2857
|
+
for (const [fName, fVal] of Object.entries(subRecord.fields)) {
|
|
2858
|
+
await this.setCompositionField(child, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks, true);
|
|
2859
|
+
}
|
|
2585
2860
|
}
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2861
|
+
// Recursively apply nested composition on the child extension
|
|
2862
|
+
await this.applyCompositionAxes(child, subRecord, child.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
|
|
2863
|
+
});
|
|
2589
2864
|
}
|
|
2590
2865
|
}
|
|
2591
2866
|
}
|