@memberjunction/metadata-sync 6.2.0-edge.4 → 6.2.0-edge.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -52
- package/dist/config.d.ts +18 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/RecordProcessor.d.ts +98 -0
- package/dist/lib/RecordProcessor.d.ts.map +1 -1
- package/dist/lib/RecordProcessor.js +424 -22
- package/dist/lib/RecordProcessor.js.map +1 -1
- package/dist/lib/RelatedEntityHandler.d.ts +4 -1
- package/dist/lib/RelatedEntityHandler.d.ts.map +1 -1
- package/dist/lib/RelatedEntityHandler.js +49 -11
- package/dist/lib/RelatedEntityHandler.js.map +1 -1
- package/dist/lib/created-record-key.d.ts +34 -0
- package/dist/lib/created-record-key.d.ts.map +1 -0
- package/dist/lib/created-record-key.js +46 -0
- package/dist/lib/created-record-key.js.map +1 -0
- package/dist/lib/deletion-auditor.d.ts.map +1 -1
- package/dist/lib/deletion-auditor.js +34 -3
- package/dist/lib/deletion-auditor.js.map +1 -1
- package/dist/lib/entity-field-names.d.ts +11 -0
- package/dist/lib/entity-field-names.d.ts.map +1 -0
- package/dist/lib/entity-field-names.js +15 -0
- package/dist/lib/entity-field-names.js.map +1 -0
- package/dist/lib/env-derived-fields.d.ts +23 -0
- package/dist/lib/env-derived-fields.d.ts.map +1 -0
- package/dist/lib/env-derived-fields.js +53 -0
- package/dist/lib/env-derived-fields.js.map +1 -0
- package/dist/lib/env-display.d.ts +54 -0
- package/dist/lib/env-display.d.ts.map +1 -0
- package/dist/lib/env-display.js +156 -0
- package/dist/lib/env-display.js.map +1 -0
- package/dist/lib/env-reference.d.ts +104 -0
- package/dist/lib/env-reference.d.ts.map +1 -0
- package/dist/lib/env-reference.js +241 -0
- package/dist/lib/env-reference.js.map +1 -0
- package/dist/lib/env-sql-redactor.d.ts +74 -0
- package/dist/lib/env-sql-redactor.d.ts.map +1 -0
- package/dist/lib/env-sql-redactor.js +232 -0
- package/dist/lib/env-sql-redactor.js.map +1 -0
- package/dist/lib/existing-record-index.d.ts +70 -0
- package/dist/lib/existing-record-index.d.ts.map +1 -0
- package/dist/lib/existing-record-index.js +158 -0
- package/dist/lib/existing-record-index.js.map +1 -0
- package/dist/lib/ignore-directories.d.ts +10 -0
- package/dist/lib/ignore-directories.d.ts.map +1 -0
- package/dist/lib/ignore-directories.js +12 -0
- package/dist/lib/ignore-directories.js.map +1 -0
- package/dist/lib/json-record-lines.d.ts +17 -0
- package/dist/lib/json-record-lines.d.ts.map +1 -0
- package/dist/lib/json-record-lines.js +170 -0
- package/dist/lib/json-record-lines.js.map +1 -0
- package/dist/lib/json-subproperty-externalization.d.ts.map +1 -1
- package/dist/lib/json-subproperty-externalization.js +3 -1
- package/dist/lib/json-subproperty-externalization.js.map +1 -1
- package/dist/lib/lookup-reference.d.ts +38 -0
- package/dist/lib/lookup-reference.d.ts.map +1 -0
- package/dist/lib/lookup-reference.js +62 -0
- package/dist/lib/lookup-reference.js.map +1 -0
- package/dist/lib/lookup-sql.d.ts +108 -0
- package/dist/lib/lookup-sql.d.ts.map +1 -0
- package/dist/lib/lookup-sql.js +231 -0
- package/dist/lib/lookup-sql.js.map +1 -0
- package/dist/lib/natural-key-preflight.d.ts +86 -0
- package/dist/lib/natural-key-preflight.d.ts.map +1 -0
- package/dist/lib/natural-key-preflight.js +173 -0
- package/dist/lib/natural-key-preflight.js.map +1 -0
- package/dist/lib/provider-utils.d.ts.map +1 -1
- package/dist/lib/provider-utils.js +2 -4
- package/dist/lib/provider-utils.js.map +1 -1
- package/dist/lib/pull-option-overrides.d.ts +24 -0
- package/dist/lib/pull-option-overrides.d.ts.map +1 -0
- package/dist/lib/pull-option-overrides.js +39 -0
- package/dist/lib/pull-option-overrides.js.map +1 -0
- package/dist/lib/push-directories.d.ts +42 -0
- package/dist/lib/push-directories.d.ts.map +1 -0
- package/dist/lib/push-directories.js +71 -0
- package/dist/lib/push-directories.js.map +1 -0
- package/dist/lib/push-when.d.ts +38 -0
- package/dist/lib/push-when.d.ts.map +1 -0
- package/dist/lib/push-when.js +84 -0
- package/dist/lib/push-when.js.map +1 -0
- package/dist/lib/record-dependency-analyzer.d.ts +11 -2
- package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
- package/dist/lib/record-dependency-analyzer.js +48 -71
- package/dist/lib/record-dependency-analyzer.js.map +1 -1
- package/dist/lib/record-file-glob.d.ts +11 -0
- package/dist/lib/record-file-glob.d.ts.map +1 -0
- package/dist/lib/record-file-glob.js +10 -0
- package/dist/lib/record-file-glob.js.map +1 -0
- package/dist/lib/reference-parser.d.ts +3 -2
- package/dist/lib/reference-parser.d.ts.map +1 -1
- package/dist/lib/reference-parser.js +27 -27
- package/dist/lib/reference-parser.js.map +1 -1
- package/dist/lib/schema-placeholders.d.ts +29 -0
- package/dist/lib/schema-placeholders.d.ts.map +1 -0
- package/dist/lib/schema-placeholders.js +72 -0
- package/dist/lib/schema-placeholders.js.map +1 -0
- package/dist/lib/sql-logger.d.ts +9 -1
- package/dist/lib/sql-logger.d.ts.map +1 -1
- package/dist/lib/sql-logger.js +12 -7
- package/dist/lib/sql-logger.js.map +1 -1
- package/dist/lib/sql-set-line.d.ts +26 -0
- package/dist/lib/sql-set-line.d.ts.map +1 -0
- package/dist/lib/sql-set-line.js +84 -0
- package/dist/lib/sql-set-line.js.map +1 -0
- package/dist/lib/sync-engine.d.ts +58 -4
- package/dist/lib/sync-engine.d.ts.map +1 -1
- package/dist/lib/sync-engine.js +243 -59
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/lib/sync-state-manager.d.ts +7 -3
- package/dist/lib/sync-state-manager.d.ts.map +1 -1
- package/dist/lib/sync-state-manager.js +19 -4
- package/dist/lib/sync-state-manager.js.map +1 -1
- package/dist/lib/watch-file-filter.d.ts +31 -0
- package/dist/lib/watch-file-filter.d.ts.map +1 -0
- package/dist/lib/watch-file-filter.js +64 -0
- package/dist/lib/watch-file-filter.js.map +1 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +44 -14
- package/dist/plugins/index.js.map +1 -1
- package/dist/services/PullService.d.ts +41 -1
- package/dist/services/PullService.d.ts.map +1 -1
- package/dist/services/PullService.js +261 -101
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.d.ts +80 -0
- package/dist/services/PushService.d.ts.map +1 -1
- package/dist/services/PushService.js +531 -189
- package/dist/services/PushService.js.map +1 -1
- package/dist/services/ValidationService.d.ts +43 -8
- package/dist/services/ValidationService.d.ts.map +1 -1
- package/dist/services/ValidationService.js +338 -127
- package/dist/services/ValidationService.js.map +1 -1
- package/dist/services/WatchService.d.ts +28 -0
- package/dist/services/WatchService.d.ts.map +1 -1
- package/dist/services/WatchService.js +226 -74
- package/dist/services/WatchService.js.map +1 -1
- package/dist/types/validation.d.ts +6 -0
- package/dist/types/validation.d.ts.map +1 -1
- package/package.json +17 -16
|
@@ -8,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,21 @@ 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
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 { ComposeStatementTransforms, LookupSqlLog } from '../lib/lookup-sql.js';
|
|
31
|
+
import { ResolveSchemaPlaceholders } from '../lib/schema-placeholders.js';
|
|
32
|
+
import { DatabaseNaturalKeyReader, NaturalKeyConflictMessage, NaturalKeyPreflight } from '../lib/natural-key-preflight.js';
|
|
33
|
+
import { CreatedRecordKeyFields, IsKeyReference, KeyFieldValue } from '../lib/created-record-key.js';
|
|
34
|
+
import { EntityFieldName, RecordFieldNames } from '../lib/entity-field-names.js';
|
|
35
|
+
import { METADATA_KEYWORDS } from '../constants/metadata-keywords.js';
|
|
36
|
+
import { DEFAULT_RECORD_FILE_PATTERN, RECORD_FILE_GLOB_OPTIONS } from '../lib/record-file-glob.js';
|
|
26
37
|
import { resolveCollectionRelationship } from '@memberjunction/record-graph';
|
|
27
38
|
function emptyPushTotals() {
|
|
28
39
|
return { created: 0, updated: 0, unchanged: 0, deleted: 0, skipped: 0, deferred: 0, errors: 0 };
|
|
@@ -64,11 +75,20 @@ export class PushService {
|
|
|
64
75
|
this.graphBatchSize = 1;
|
|
65
76
|
/** Counts so far, so a failed push can still report what it did. */
|
|
66
77
|
this.runningTotals = emptyPushTotals();
|
|
78
|
+
/** The open SQL log's redactor and lookup-create hook; null when no log is open. */
|
|
79
|
+
this.envSqlLog = null;
|
|
80
|
+
/** The open SQL log's `@lookup` → subquery rewriter and lookup-create hook; null when no log is open. */
|
|
81
|
+
this.lookupSqlLog = null;
|
|
82
|
+
/** Unique keys of each entity's table read for the create pre-flight (#4400), by entity name. */
|
|
83
|
+
this.uniqueKeyCache = new Map();
|
|
84
|
+
/** Composition child fields waiting for their owner's save, by the owner record's resolution collector. */
|
|
85
|
+
this.pendingEnvSqlChildFields = new WeakMap();
|
|
67
86
|
/** Non-atomic mode: creates and updates that were committed outside the push transaction. */
|
|
68
87
|
this.committedWrites = [];
|
|
69
88
|
/** Incremental state, applied only after the push commits. Keyed by path relative to the sync root. */
|
|
70
89
|
this.pendingChecksums = new Map();
|
|
71
90
|
this.pushedEntityDirs = [];
|
|
91
|
+
this.skippedDirectories = [];
|
|
72
92
|
/** Errors whose record was already sent to onRecordError, so a caller does not report it twice. */
|
|
73
93
|
this.reportedRecordErrors = new WeakSet();
|
|
74
94
|
this.syncEngine = syncEngine;
|
|
@@ -179,14 +199,18 @@ export class PushService {
|
|
|
179
199
|
this.committedWrites = [];
|
|
180
200
|
this.pendingChecksums.clear();
|
|
181
201
|
this.pushedEntityDirs = [];
|
|
202
|
+
this.skippedDirectories = [];
|
|
182
203
|
this.directoryModes.clear();
|
|
183
204
|
this.runningTotals = emptyPushTotals();
|
|
184
205
|
this.sqlLogFilePath = undefined;
|
|
206
|
+
this.closeEnvSqlRedactor();
|
|
185
207
|
const fileBackupManager = new FileBackupManager();
|
|
186
208
|
// Load sync config for SQL logging settings and autoCreateMissingRecords flag
|
|
187
|
-
//
|
|
188
|
-
const
|
|
189
|
-
|
|
209
|
+
// With the dir option, load from the sync root at or above that directory, otherwise from the original CWD
|
|
210
|
+
const folders = await ResolvePushFolders(configManager.getOriginalCwd(), options.dir, options.include, options.exclude);
|
|
211
|
+
const configDir = folders.ConfigDir;
|
|
212
|
+
const syncRootDir = folders.SyncRootDir;
|
|
213
|
+
this.syncConfig = folders.SyncConfig;
|
|
190
214
|
// Display warnings for special flags that are enabled
|
|
191
215
|
if (this.syncConfig?.push?.alwaysPush && !options.dryRun) {
|
|
192
216
|
callbacks?.onWarn?.('\n⚡ WARNING: alwaysPush is enabled - ALL records will be saved to database regardless of changes\n');
|
|
@@ -197,7 +221,7 @@ export class PushService {
|
|
|
197
221
|
if (options.verbose) {
|
|
198
222
|
callbacks?.onLog?.(`Original working directory: ${configManager.getOriginalCwd()}`);
|
|
199
223
|
callbacks?.onLog?.(`Config directory (with dir option): ${configDir}`);
|
|
200
|
-
callbacks?.onLog?.(`Config file path: ${path.join(
|
|
224
|
+
callbacks?.onLog?.(`Config file path: ${path.join(syncRootDir, '.mj-sync.json')}`);
|
|
201
225
|
callbacks?.onLog?.(`Full sync config loaded: ${JSON.stringify(this.syncConfig, null, 2)}`);
|
|
202
226
|
callbacks?.onLog?.(`SQL logging config: ${JSON.stringify(this.syncConfig?.sqlLogging)}`);
|
|
203
227
|
}
|
|
@@ -210,13 +234,12 @@ export class PushService {
|
|
|
210
234
|
// Setup SQL logging session with the provider if enabled
|
|
211
235
|
let sqlLoggingSession = null;
|
|
212
236
|
try {
|
|
213
|
-
sqlLoggingSession = await this.startSqlLogging(sqlLogger, options,
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
throw new Error('No entity directories found');
|
|
237
|
+
sqlLoggingSession = await this.startSqlLogging(sqlLogger, options, syncRootDir, callbacks);
|
|
238
|
+
const selection = folders.SelectDirectories();
|
|
239
|
+
const entityDirs = selection.Included;
|
|
240
|
+
this.skippedDirectories = selection.Skipped;
|
|
241
|
+
for (const skipped of selection.Skipped) {
|
|
242
|
+
callbacks?.onLog?.(SkippedDirectoryMessage(configDir, skipped));
|
|
220
243
|
}
|
|
221
244
|
await this.applyWritePlan(entityDirs, options, callbacks);
|
|
222
245
|
await this.preloadMetadata(entityDirs, options, callbacks);
|
|
@@ -253,7 +276,8 @@ export class PushService {
|
|
|
253
276
|
deferred: 0,
|
|
254
277
|
errors: 0,
|
|
255
278
|
warnings: this.warnings,
|
|
256
|
-
changeLog: this.changeDetails
|
|
279
|
+
changeLog: this.changeDetails,
|
|
280
|
+
SkippedDirectories: this.skippedDirectories
|
|
257
281
|
};
|
|
258
282
|
}
|
|
259
283
|
}
|
|
@@ -263,7 +287,7 @@ export class PushService {
|
|
|
263
287
|
if (!options.dryRun) {
|
|
264
288
|
await transactionManager.beginTransaction();
|
|
265
289
|
}
|
|
266
|
-
const totals = await this.runPushInTransaction({ entityDirs, deletionAudit, options, fileBackupManager, callbacks, configDir }, transactionManager);
|
|
290
|
+
const totals = await this.runPushInTransaction({ entityDirs, deletionAudit, options, fileBackupManager, callbacks, syncRootDir, belowSyncRoot: syncRootDir !== configDir }, transactionManager);
|
|
267
291
|
if (!options.dryRun) {
|
|
268
292
|
await fileBackupManager.cleanup();
|
|
269
293
|
if (options.verbose) {
|
|
@@ -281,16 +305,19 @@ export class PushService {
|
|
|
281
305
|
await sqlLoggingSession.dispose();
|
|
282
306
|
if (hadStatements) {
|
|
283
307
|
sqlLogPath = filePath;
|
|
308
|
+
this.addEnvSqlWarnings(filePath, callbacks);
|
|
284
309
|
if (options.verbose) {
|
|
285
310
|
callbacks?.onLog?.(`📝 SQL log written to: ${filePath}`);
|
|
286
311
|
}
|
|
287
312
|
}
|
|
313
|
+
this.closeEnvSqlRedactor();
|
|
288
314
|
}
|
|
289
315
|
return {
|
|
290
316
|
...totals,
|
|
291
317
|
warnings: this.warnings,
|
|
292
318
|
sqlLogPath,
|
|
293
|
-
changeLog: this.changeDetails
|
|
319
|
+
changeLog: this.changeDetails,
|
|
320
|
+
SkippedDirectories: this.skippedDirectories
|
|
294
321
|
};
|
|
295
322
|
}
|
|
296
323
|
catch (error) {
|
|
@@ -308,10 +335,16 @@ export class PushService {
|
|
|
308
335
|
if (sqlLoggingSession) {
|
|
309
336
|
try {
|
|
310
337
|
await sqlLoggingSession.dispose();
|
|
338
|
+
if (await fs.pathExists(sqlLoggingSession.filePath)) {
|
|
339
|
+
this.addEnvSqlWarnings(sqlLoggingSession.filePath, callbacks);
|
|
340
|
+
}
|
|
311
341
|
}
|
|
312
342
|
catch (disposeError) {
|
|
313
343
|
callbacks?.onWarn?.(`Failed to close SQL logging session: ${disposeError}`);
|
|
314
344
|
}
|
|
345
|
+
finally {
|
|
346
|
+
this.closeEnvSqlRedactor();
|
|
347
|
+
}
|
|
315
348
|
}
|
|
316
349
|
throw error;
|
|
317
350
|
}
|
|
@@ -321,7 +354,7 @@ export class PushService {
|
|
|
321
354
|
return this.Push(options, callbacks);
|
|
322
355
|
}
|
|
323
356
|
/** Open the SQL logging session when the config asks for one and this is not a dry run. */
|
|
324
|
-
async startSqlLogging(sqlLogger, options,
|
|
357
|
+
async startSqlLogging(sqlLogger, options, syncRootDir, callbacks) {
|
|
325
358
|
let sqlLoggingSession = null;
|
|
326
359
|
if (sqlLogger.enabled && !options.dryRun) {
|
|
327
360
|
const provider = Metadata.Provider; // global-provider-ok: metadata sync operates on the configured provider only
|
|
@@ -336,15 +369,24 @@ export class PushService {
|
|
|
336
369
|
const filename = this.syncConfig?.sqlLogging?.formatAsMigration
|
|
337
370
|
? `MetadataSync_Push_${timestamp}.sql`
|
|
338
371
|
: `push_${timestamp}.sql`;
|
|
339
|
-
//
|
|
340
|
-
const outputDir = path.join(
|
|
372
|
+
// The SQL log folder is relative to the sync root
|
|
373
|
+
const outputDir = path.join(syncRootDir, this.syncConfig?.sqlLogging?.outputDirectory || './sql-log-push');
|
|
341
374
|
const filepath = path.join(outputDir, filename);
|
|
342
375
|
// Ensure the directory exists
|
|
343
376
|
await fs.ensureDir(path.dirname(filepath));
|
|
344
377
|
// Create the SQL logging session
|
|
345
378
|
this.sqlLogFilePath = filepath;
|
|
379
|
+
this.envSqlLog = new EnvSqlLog(this.syncEngine);
|
|
380
|
+
this.lookupSqlLog = new LookupSqlLog(this.syncEngine);
|
|
381
|
+
const schemaPlaceholders = ResolveSchemaPlaceholders(this.syncConfig, configManager.GetMJConfig());
|
|
346
382
|
sqlLoggingSession = await provider.CreateSqlLogger(filepath, {
|
|
383
|
+
// Env values are left out first, then lookup IDs become subqueries; the logger's schema
|
|
384
|
+
// placeholder rewrite runs after both, so the subqueries' schema names become placeholders.
|
|
385
|
+
TransformStatement: ComposeStatementTransforms(this.envSqlLog.Redactor.Transform, this.lookupSqlLog.Rewriter.Transform),
|
|
347
386
|
formatAsMigration: this.syncConfig?.sqlLogging?.formatAsMigration || false,
|
|
387
|
+
schemaPlaceholders,
|
|
388
|
+
// Escaping is tied to migration output, never to whether placeholder rules exist
|
|
389
|
+
escapeFlywaySyntax: this.syncConfig?.sqlLogging?.formatAsMigration === true || this.syncConfig?.sqlLogging?.escapeFlywaySyntax === true,
|
|
348
390
|
description: 'MetadataSync push operation',
|
|
349
391
|
statementTypes: "mutations",
|
|
350
392
|
prettyPrint: true,
|
|
@@ -353,6 +395,7 @@ export class PushService {
|
|
|
353
395
|
filterPatterns: this.syncConfig?.sqlLogging?.filterPatterns,
|
|
354
396
|
filterType: this.syncConfig?.sqlLogging?.filterType,
|
|
355
397
|
verboseOutput: this.syncConfig?.sqlLogging?.verboseOutput || false,
|
|
398
|
+
variableBatchThreshold: this.syncConfig?.sqlLogging?.variableBatchThreshold ?? 200,
|
|
356
399
|
});
|
|
357
400
|
if (options.verbose) {
|
|
358
401
|
callbacks?.onLog?.(`📝 SQL logging enabled: ${filepath}`);
|
|
@@ -419,8 +462,18 @@ export class PushService {
|
|
|
419
462
|
catch (cleanupError) {
|
|
420
463
|
callbacks?.onWarn?.(`Failed to clean up SQL logging session: ${cleanupError}`);
|
|
421
464
|
}
|
|
465
|
+
finally {
|
|
466
|
+
this.closeEnvSqlRedactor();
|
|
467
|
+
}
|
|
422
468
|
}
|
|
423
469
|
}
|
|
470
|
+
/** Drops the SQL log redactor and this push's lookup-create hook, after the log is closed. */
|
|
471
|
+
closeEnvSqlRedactor() {
|
|
472
|
+
this.envSqlLog?.Close();
|
|
473
|
+
this.envSqlLog = null;
|
|
474
|
+
this.lookupSqlLog?.Close();
|
|
475
|
+
this.lookupSqlLog = null;
|
|
476
|
+
}
|
|
424
477
|
/**
|
|
425
478
|
* Decide, once and up front, how each entity directory writes: shared by default, isolated where
|
|
426
479
|
* the entity (or the root, or the CLI flag) asks for it. Deciding it here means a push can never
|
|
@@ -526,7 +579,7 @@ export class PushService {
|
|
|
526
579
|
committed = true;
|
|
527
580
|
// PHASE 3: files that contained deletions, then the incremental state
|
|
528
581
|
await this.writeDeferredFiles(run.options, run.callbacks);
|
|
529
|
-
await this.persistIncrementalState(run.
|
|
582
|
+
await this.persistIncrementalState(run.syncRootDir, run.belowSyncRoot);
|
|
530
583
|
}
|
|
531
584
|
return totals;
|
|
532
585
|
}
|
|
@@ -653,7 +706,7 @@ export class PushService {
|
|
|
653
706
|
: '❌ The push transaction was still open when the push ended, and rolling it back failed.');
|
|
654
707
|
}
|
|
655
708
|
/** Apply incremental checksums and push timestamps. Called only after the push commits. */
|
|
656
|
-
async persistIncrementalState(syncRootDir) {
|
|
709
|
+
async persistIncrementalState(syncRootDir, belowSyncRoot) {
|
|
657
710
|
if (!this.stateManager) {
|
|
658
711
|
return;
|
|
659
712
|
}
|
|
@@ -664,7 +717,7 @@ export class PushService {
|
|
|
664
717
|
for (const relativeEntityDir of this.pushedEntityDirs) {
|
|
665
718
|
this.stateManager.setLastPushTimestamp(relativeEntityDir, pushedAt);
|
|
666
719
|
}
|
|
667
|
-
await this.stateManager.pruneStaleChecksums(syncRootDir);
|
|
720
|
+
await this.stateManager.pruneStaleChecksums(syncRootDir, belowSyncRoot ? this.pushedEntityDirs : undefined);
|
|
668
721
|
await this.stateManager.save();
|
|
669
722
|
}
|
|
670
723
|
/** PHASE 1 over every entity directory, in order. */
|
|
@@ -686,7 +739,7 @@ export class PushService {
|
|
|
686
739
|
this.useDirectoryMode(entityDir, options);
|
|
687
740
|
this.announceDirectory(dirName, progressPrefix, entityConfig.entity, entityDir, options, callbacks);
|
|
688
741
|
const firstChange = this.changeDetails.length;
|
|
689
|
-
const result = await this.processEntityDirectory(entityDir, entityConfig, options, run.fileBackupManager, callbacks, run.
|
|
742
|
+
const result = await this.processEntityDirectory(entityDir, entityConfig, options, run.fileBackupManager, callbacks, run.syncRootDir);
|
|
690
743
|
// Nested relatedEntities land in changeDetails under their own entity name, so the
|
|
691
744
|
// directory's configured entity is not what decides the reload (MJ#4836).
|
|
692
745
|
await this.reloadMetadataIfTouched(firstChange, options.dryRun);
|
|
@@ -758,14 +811,8 @@ export class PushService {
|
|
|
758
811
|
callbacks?.onWarn?.(`⚠️ ${subclassWarning}`);
|
|
759
812
|
}
|
|
760
813
|
// 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
|
-
});
|
|
814
|
+
const pattern = entityConfig.filePattern || DEFAULT_RECORD_FILE_PATTERN;
|
|
815
|
+
const files = await fastGlob(pattern, { ...RECORD_FILE_GLOB_OPTIONS, cwd: entityDir });
|
|
769
816
|
if (options.verbose) {
|
|
770
817
|
callbacks?.onLog?.(`Found ${files.length} files to process`);
|
|
771
818
|
}
|
|
@@ -799,12 +846,13 @@ export class PushService {
|
|
|
799
846
|
// Keep unprocessed data to write back (preserves @file: references)
|
|
800
847
|
const unprocessedRecords = Array.isArray(rawFileData) ? rawFileData : [rawFileData];
|
|
801
848
|
const isArray = Array.isArray(rawFileData);
|
|
802
|
-
// Compute checksum on the RESOLVED content (after @include preprocessing)
|
|
803
|
-
// so
|
|
849
|
+
// Compute checksum on the RESOLVED content (after @include preprocessing), with the content
|
|
850
|
+
// of each @file: target folded in, so a change to an included or referenced file is
|
|
851
|
+
// detected (#4190). @file: paths resolve against the entity folder, as push resolves them.
|
|
804
852
|
let cachedChecksum;
|
|
805
853
|
if (options.incremental && this.stateManager && syncRootDir) {
|
|
806
854
|
const relativePath = path.relative(syncRootDir, filePath);
|
|
807
|
-
cachedChecksum = this.syncEngine.
|
|
855
|
+
cachedChecksum = await this.syncEngine.CalculateChecksumWithFileContent(fileData, entityDir);
|
|
808
856
|
if (!this.stateManager.hasFileChanged(relativePath, cachedChecksum)) {
|
|
809
857
|
const recordCount = Array.isArray(rawFileData) ? rawFileData.length : 1;
|
|
810
858
|
if (options.verbose) {
|
|
@@ -818,6 +866,9 @@ export class PushService {
|
|
|
818
866
|
// Analyze dependencies and get sorted records
|
|
819
867
|
const analyzer = new RecordDependencyAnalyzer();
|
|
820
868
|
const analysisResult = await analyzer.analyzeFileRecords(records, entityConfig.entity);
|
|
869
|
+
for (const flattened of analysisResult.sortedRecords) {
|
|
870
|
+
flattened.FilePath = filePath;
|
|
871
|
+
}
|
|
821
872
|
if (analysisResult.circularDependencies.length > 0) {
|
|
822
873
|
callbacks?.onWarn?.(`⚠️ Circular dependencies detected in ${filePath}`);
|
|
823
874
|
for (const cycle of analysisResult.circularDependencies) {
|
|
@@ -913,7 +964,7 @@ export class PushService {
|
|
|
913
964
|
// Uses resolved content (after @include) so included-file changes are tracked.
|
|
914
965
|
if (this.stateManager && syncRootDir) {
|
|
915
966
|
const relativePath = path.relative(syncRootDir, filePath);
|
|
916
|
-
const checksum = cachedChecksum ?? this.syncEngine.
|
|
967
|
+
const checksum = cachedChecksum ?? (await this.syncEngine.CalculateChecksumWithFileContent(fileData, entityDir));
|
|
917
968
|
this.pendingChecksums.set(relativePath, checksum);
|
|
918
969
|
}
|
|
919
970
|
}
|
|
@@ -1056,8 +1107,8 @@ export class PushService {
|
|
|
1056
1107
|
pendingWrites.set(outcome.graphId, writes);
|
|
1057
1108
|
}
|
|
1058
1109
|
throwRecordFailure(failure, callbacks) {
|
|
1059
|
-
const err = failure.error instanceof Error ? failure.error : new Error(String(failure.error));
|
|
1060
1110
|
const rec = failure.record;
|
|
1111
|
+
const err = this.locateRecordError(failure.error instanceof Error ? failure.error : new Error(String(failure.error)), rec);
|
|
1061
1112
|
callbacks?.onLog?.(`\n❌ Processing failed for ${rec.entityName} at ${rec.path}`);
|
|
1062
1113
|
callbacks?.onLog?.(` ${err.message}\n`);
|
|
1063
1114
|
if (err.stack) {
|
|
@@ -1065,6 +1116,33 @@ export class PushService {
|
|
|
1065
1116
|
}
|
|
1066
1117
|
throw err;
|
|
1067
1118
|
}
|
|
1119
|
+
/**
|
|
1120
|
+
* For an unset `@env:` variable, an error that names the record file with the record's start line,
|
|
1121
|
+
* the entity and the field: `/abs/file.json:12: Entity fields.Name uses @env:VAR, which is not set`.
|
|
1122
|
+
* Other errors are returned as they are.
|
|
1123
|
+
*/
|
|
1124
|
+
locateRecordError(error, record) {
|
|
1125
|
+
if (!(error instanceof EnvVariableNotSetError)) {
|
|
1126
|
+
return error;
|
|
1127
|
+
}
|
|
1128
|
+
const field = error.Field ?? 'a field';
|
|
1129
|
+
const message = `${this.recordLocation(record)}: ${error.EntityName ?? record.entityName} ${field} uses @env:${error.VariableName}, which is not set`;
|
|
1130
|
+
return new Error(message, { cause: error });
|
|
1131
|
+
}
|
|
1132
|
+
/** `/abs/file.json:12` for a record from a file (the line where the record starts, when found), else the record path. */
|
|
1133
|
+
recordLocation(record) {
|
|
1134
|
+
if (!record.FilePath) {
|
|
1135
|
+
return record.path;
|
|
1136
|
+
}
|
|
1137
|
+
const filePath = path.resolve(record.FilePath);
|
|
1138
|
+
try {
|
|
1139
|
+
const line = RecordStartLine(fs.readFileSync(filePath, 'utf8'), record.path);
|
|
1140
|
+
return line === undefined ? filePath : `${filePath}:${line}`;
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
return filePath;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1068
1146
|
async processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig, allowDefer = true, recordProvider) {
|
|
1069
1147
|
const metadata = new Metadata(); // global-provider-ok: metadata sync operates on the configured provider only
|
|
1070
1148
|
const { record, entityName, parentContext, id: recordId } = flattenedRecord;
|
|
@@ -1080,13 +1158,21 @@ export class PushService {
|
|
|
1080
1158
|
// Use the unique record ID from the flattened record for batch context
|
|
1081
1159
|
// This ensures we can properly find parent entities even when they're new
|
|
1082
1160
|
const lookupKey = recordId;
|
|
1083
|
-
// Check if already in batch context
|
|
1161
|
+
// Check if already in batch context; the deferred retry runs again for a record the main pass added
|
|
1084
1162
|
const existingEntry = batchContext.get(lookupKey);
|
|
1085
|
-
if (existingEntry) {
|
|
1163
|
+
if (existingEntry && allowDefer) {
|
|
1086
1164
|
// Already processed
|
|
1087
1165
|
return { status: 'unchanged', isDuplicate: true };
|
|
1088
1166
|
}
|
|
1089
1167
|
let entity;
|
|
1168
|
+
// Parent entity from the batch context, for @parent refs in primaryKey
|
|
1169
|
+
let parentEntity = null;
|
|
1170
|
+
if (parentContext) {
|
|
1171
|
+
const parentRecordId = flattenedRecord.dependencies.values().next().value;
|
|
1172
|
+
if (parentRecordId) {
|
|
1173
|
+
parentEntity = batchContext.get(parentRecordId) || null;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1090
1176
|
// Record-level skip (--incremental only): if the record has a stored
|
|
1091
1177
|
// checksum and it matches the current fields, nothing changed — skip
|
|
1092
1178
|
// without touching the DB. The checksum is embedded in the JSON
|
|
@@ -1100,16 +1186,20 @@ export class PushService {
|
|
|
1100
1186
|
// drift-correction behavior single-env workflows rely on, and prevents
|
|
1101
1187
|
// silent no-ops when seeding a fresh DB from JSON files that already
|
|
1102
1188
|
// carry stored checksums from another environment.
|
|
1103
|
-
|
|
1189
|
+
// The deferred retry (allowDefer false) never takes this shortcut: the first pass left fields unsaved
|
|
1190
|
+
if (allowDefer && options.incremental && record.sync?.checksum && record.fields && record.primaryKey && !record.deleteRecord) {
|
|
1104
1191
|
// Use calculateChecksumWithFileContent so @file: reference changes are detected.
|
|
1105
1192
|
// This reads local files (fast) rather than hitting the DB (slow). The stored
|
|
1106
1193
|
// checksum was also computed with file content and composition payloads, so the comparison is apples-to-apples.
|
|
1107
1194
|
const checksumPayload = this.buildRecordChecksumPayload(record);
|
|
1108
1195
|
const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(checksumPayload, entityDir);
|
|
1109
1196
|
if (currentChecksum === record.sync.checksum) {
|
|
1110
|
-
// Lightweight stub — just enough for @parent:ID and @lookup resolution.
|
|
1111
|
-
//
|
|
1112
|
-
|
|
1197
|
+
// Lightweight stub — just enough for @parent:ID and @lookup resolution. No entity load.
|
|
1198
|
+
// @env:, @parent: and @owner: key values are resolved without a DB call, so a child's @parent:
|
|
1199
|
+
// reference copies the value; other key values (such as @lookup:) stay raw.
|
|
1200
|
+
const keyCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map() };
|
|
1201
|
+
const shortcutKey = await this.resolvePrimaryKey(record.primaryKey, entityDir, parentEntity, batchContext, keyCollector, recordProvider, true);
|
|
1202
|
+
const allFields = { ...record.fields, ...shortcutKey };
|
|
1113
1203
|
const stub = {
|
|
1114
1204
|
EntityInfo: { Name: entityName, PrimaryKeys: Object.keys(record.primaryKey).map(k => ({ Name: k })), Fields: Object.keys(allFields).map(k => ({ Name: k })) },
|
|
1115
1205
|
Get: (field) => allFields[field],
|
|
@@ -1126,37 +1216,18 @@ export class PushService {
|
|
|
1126
1216
|
if (!entity) {
|
|
1127
1217
|
throw new Error(`Failed to create entity object for ${entityName}`);
|
|
1128
1218
|
}
|
|
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
|
-
}
|
|
1219
|
+
if (parentContext && !parentEntity) {
|
|
1220
|
+
throw new Error(`Parent entity not found in batch context for ${entityName}. Parent dependencies: ${Array.from(flattenedRecord.dependencies).join(', ')}`);
|
|
1139
1221
|
}
|
|
1140
1222
|
// Check if record exists
|
|
1141
1223
|
// Process primaryKey values through processFieldValue to resolve @lookup, @parent, etc.
|
|
1142
1224
|
// Create a resolution collector to track @lookup and @parent resolutions
|
|
1143
|
-
const resolutionCollector = { notes: [], fieldPrefix: 'primaryKey' };
|
|
1225
|
+
const resolutionCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map(), LookupSources: new Map() };
|
|
1226
|
+
// Reads the same EnvFields map, so fields recorded later are masked too
|
|
1227
|
+
const envMask = new EnvDisplayMask(resolutionCollector.EnvFields);
|
|
1144
1228
|
let resolvedPrimaryKey;
|
|
1145
1229
|
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
|
-
}
|
|
1230
|
+
resolvedPrimaryKey = await this.resolvePrimaryKey(record.primaryKey, entityDir, parentEntity, batchContext, resolutionCollector, recordProvider);
|
|
1160
1231
|
}
|
|
1161
1232
|
let exists = false;
|
|
1162
1233
|
let isNew = false;
|
|
@@ -1176,7 +1247,7 @@ export class PushService {
|
|
|
1176
1247
|
// Record doesn't exist in database
|
|
1177
1248
|
const autoCreate = this.syncConfig?.push?.autoCreateMissingRecords ?? false;
|
|
1178
1249
|
const pkDisplay = Object.entries(resolvedPrimaryKey)
|
|
1179
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
1250
|
+
.map(([key, value]) => `${key}=${envMask.NewValue(key, String(value))}`)
|
|
1180
1251
|
.join(', ');
|
|
1181
1252
|
if (!autoCreate) {
|
|
1182
1253
|
const warning = `Record not found: ${entityName} with primaryKey {${pkDisplay}}. To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`;
|
|
@@ -1212,11 +1283,32 @@ export class PushService {
|
|
|
1212
1283
|
}
|
|
1213
1284
|
// Apply defaults if entityConfig is provided
|
|
1214
1285
|
if (entityConfig) {
|
|
1215
|
-
const
|
|
1286
|
+
const defaultsCollector = { notes: [], fieldPrefix: 'fields', EnvFields: new Map() };
|
|
1287
|
+
// Folder defaults come from the record file's folders
|
|
1288
|
+
const recordFile = flattenedRecord.FilePath ?? path.join(entityDir, '.record.json');
|
|
1289
|
+
const skippedDefaults = new Map();
|
|
1290
|
+
const defaults = await this.syncEngine.buildDefaults(recordFile, entityConfig, defaultsCollector, skippedDefaults);
|
|
1291
|
+
// Field names match the entity's fields without case; a default for a virtual property applies when the entity has that property
|
|
1292
|
+
const recordFields = new RecordFieldNames(record.fields);
|
|
1293
|
+
// A default left out for an unset optional variable warns when the record would have used it
|
|
1294
|
+
for (const [field, variableName] of skippedDefaults) {
|
|
1295
|
+
const name = EntityFieldName(entity, field) ?? (field in entity ? field : undefined);
|
|
1296
|
+
if (name && !recordFields.Has(name)) {
|
|
1297
|
+
const warning = OptionalEnvSkipMessage(entityName, name, variableName);
|
|
1298
|
+
localWarnings.push(warning);
|
|
1299
|
+
callbacks?.onWarn?.(`⚠️ ${warning}`);
|
|
1300
|
+
AddSkippedOptionalEnvField(resolutionCollector.EnvFields, name, variableName);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1216
1303
|
// Apply defaults only to fields not explicitly set in record.fields
|
|
1217
1304
|
for (const [field, value] of Object.entries(defaults)) {
|
|
1218
|
-
|
|
1219
|
-
|
|
1305
|
+
const name = EntityFieldName(entity, field) ?? (field in entity ? field : undefined);
|
|
1306
|
+
if (name && !recordFields.Has(name)) {
|
|
1307
|
+
entity.Set(name, value);
|
|
1308
|
+
const variables = defaultsCollector.EnvFields?.get(field);
|
|
1309
|
+
if (variables) {
|
|
1310
|
+
resolutionCollector.EnvFields?.set(name, variables);
|
|
1311
|
+
}
|
|
1220
1312
|
}
|
|
1221
1313
|
}
|
|
1222
1314
|
}
|
|
@@ -1277,6 +1369,16 @@ export class PushService {
|
|
|
1277
1369
|
}
|
|
1278
1370
|
}
|
|
1279
1371
|
catch (fieldError) {
|
|
1372
|
+
if (fieldError instanceof EnvVariableNotSetError) {
|
|
1373
|
+
throw fieldError.ForField(`fields.${fieldName}`);
|
|
1374
|
+
}
|
|
1375
|
+
if (fieldError instanceof OptionalEnvMissingError) {
|
|
1376
|
+
const warning = OptionalEnvSkipMessage(entityName, fieldName, fieldError.VariableName);
|
|
1377
|
+
localWarnings.push(warning);
|
|
1378
|
+
callbacks?.onWarn?.(`⚠️ ${warning}`);
|
|
1379
|
+
AddSkippedOptionalEnvField(resolutionCollector.EnvFields, EntityFieldName(entity, fieldName) ?? fieldName, fieldError.VariableName);
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1280
1382
|
// Check if this is a deferrable lookup error first
|
|
1281
1383
|
if (fieldError instanceof DeferrableLookupError) {
|
|
1282
1384
|
// If allowDefer is false, we're in deferred processing mode - can't defer again
|
|
@@ -1376,15 +1478,16 @@ export class PushService {
|
|
|
1376
1478
|
const primaryKeyDisplay = [];
|
|
1377
1479
|
if (entityInfo) {
|
|
1378
1480
|
for (const pk of entityInfo.PrimaryKeys) {
|
|
1379
|
-
primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
|
|
1481
|
+
primaryKeyDisplay.push(`${pk.Name}: ${envMask.NewValue(pk.Name, String(entity.Get(pk.Name)))}`);
|
|
1380
1482
|
}
|
|
1381
1483
|
}
|
|
1382
1484
|
const fieldDiffs = changeKeys.map((fieldName) => {
|
|
1383
1485
|
const field = entity.GetFieldByName(fieldName);
|
|
1486
|
+
const envName = field?.Name ?? fieldName;
|
|
1384
1487
|
return {
|
|
1385
1488
|
field: fieldName,
|
|
1386
|
-
oldValue: this.formatFieldValue(field ? field.OldValue : undefined),
|
|
1387
|
-
newValue: this.formatFieldValue(changes[fieldName]),
|
|
1489
|
+
oldValue: envMask.OldValue(envName, this.formatFieldValue(field ? field.OldValue : undefined)),
|
|
1490
|
+
newValue: envMask.NewValue(envName, this.formatFieldValue(changes[fieldName])),
|
|
1388
1491
|
};
|
|
1389
1492
|
});
|
|
1390
1493
|
this.changeDetails.push({
|
|
@@ -1415,6 +1518,11 @@ export class PushService {
|
|
|
1415
1518
|
status: 'unchanged',
|
|
1416
1519
|
};
|
|
1417
1520
|
}
|
|
1521
|
+
// A literal key that missed can still collide on a unique key with a row this database made
|
|
1522
|
+
// under another ID: report that row instead of a constraint violation (#4400).
|
|
1523
|
+
if (isNew && resolvedPrimaryKey && Object.keys(resolvedPrimaryKey).length > 0) {
|
|
1524
|
+
await this.checkNaturalKey(entity, resolvedPrimaryKey, envMask, recordProvider);
|
|
1525
|
+
}
|
|
1418
1526
|
// A dry run stops here, after the same change detection a real push uses, so an
|
|
1419
1527
|
// in-sync record reports unchanged above rather than "would update" (#4529).
|
|
1420
1528
|
if (options.dryRun) {
|
|
@@ -1424,7 +1532,7 @@ export class PushService {
|
|
|
1424
1532
|
const batchContextEntry = { key: lookupKey, entity };
|
|
1425
1533
|
if (exists) {
|
|
1426
1534
|
callbacks?.onLog?.(`[DRY RUN] Would update ${entityName} record`);
|
|
1427
|
-
return { status: 'updated', batchContextEntry };
|
|
1535
|
+
return { status: 'updated', batchContextEntry, warnings: localWarnings.length > 0 ? localWarnings : undefined };
|
|
1428
1536
|
}
|
|
1429
1537
|
else {
|
|
1430
1538
|
callbacks?.onLog?.(`[DRY RUN] Would create ${entityName} record`);
|
|
@@ -1433,20 +1541,27 @@ export class PushService {
|
|
|
1433
1541
|
// a throwaway UUID that changes every run and is never the one a push writes.
|
|
1434
1542
|
const entityInfo = this.syncEngine.getEntityInfo(entityName);
|
|
1435
1543
|
const primaryKeyDisplay = (entityInfo?.PrimaryKeys ?? [])
|
|
1436
|
-
.filter((pk) => resolvedPrimaryKey
|
|
1437
|
-
.map((pk) => `${pk.Name}: ${resolvedPrimaryKey
|
|
1544
|
+
.filter((pk) => KeyFieldValue(resolvedPrimaryKey, pk.Name) != null || KeyFieldValue(record.fields, pk.Name) != null)
|
|
1545
|
+
.map((pk) => `${pk.Name}: ${envMask.NewValue(pk.Name, String(KeyFieldValue(resolvedPrimaryKey, pk.Name) ?? entity.Get(pk.Name)))}`);
|
|
1438
1546
|
this.changeDetails.push({
|
|
1439
1547
|
entityName,
|
|
1440
1548
|
primaryKey: primaryKeyDisplay.length > 0 ? primaryKeyDisplay.join(', ') : 'new',
|
|
1441
1549
|
Operation: 'created',
|
|
1442
1550
|
fields: [],
|
|
1443
1551
|
});
|
|
1444
|
-
return { status: 'created', batchContextEntry };
|
|
1552
|
+
return { status: 'created', batchContextEntry, warnings: localWarnings.length > 0 ? localWarnings : undefined };
|
|
1445
1553
|
}
|
|
1446
1554
|
}
|
|
1555
|
+
this.registerEnvSqlFields(entity, resolutionCollector.EnvFields, isNew || alwaysPush);
|
|
1556
|
+
this.registerPendingEnvSqlChildFields(resolutionCollector);
|
|
1557
|
+
// Key and foreign-key values from @lookup are logged as subqueries on the natural key (#5626)
|
|
1558
|
+
this.lookupSqlLog?.Rewriter.RegisterEntityLookups(entity, resolutionCollector.LookupSources);
|
|
1447
1559
|
// Save the record with detailed error logging
|
|
1560
|
+
const keyBeforeSave = new Map(entity.PrimaryKeys.map((pk) => [pk.Name, entity.Get(pk.Name)]));
|
|
1448
1561
|
const recordName = entity.Get('Name');
|
|
1449
|
-
const
|
|
1562
|
+
const recordKeyText = this.recordIdLabel(entity, envMask);
|
|
1563
|
+
// Free error text can hold a value of any variable push resolved, composition children included
|
|
1564
|
+
const errorTextMask = EnvDisplayMask.ForVariables(this.syncEngine.ResolvedEnvVariables);
|
|
1450
1565
|
let saveResult;
|
|
1451
1566
|
try {
|
|
1452
1567
|
// Skip embedding generation during sync — vectors can be computed later by the
|
|
@@ -1466,10 +1581,10 @@ export class PushService {
|
|
|
1466
1581
|
callbacks?.onLog?.(msg);
|
|
1467
1582
|
};
|
|
1468
1583
|
logError(`\n❌ SAVE EXCEPTION for ${entityName}`);
|
|
1469
|
-
logError(` Record ID: ${
|
|
1470
|
-
logError(` Record Name: ${recordName
|
|
1584
|
+
logError(` Record ID: ${recordKeyText}`);
|
|
1585
|
+
logError(` Record Name: ${recordName ? envMask.NewValue('Name', String(recordName)) : 'N/A'}`);
|
|
1471
1586
|
logError(` File Path: ${flattenedRecord.path}`);
|
|
1472
|
-
logError(` Error: ${saveError.message || saveError}`);
|
|
1587
|
+
logError(` Error: ${errorTextMask.MaskText(String(saveError.message || saveError))}`);
|
|
1473
1588
|
// Check for specific error patterns
|
|
1474
1589
|
if (saveError.message?.includes('Cannot insert the value NULL')) {
|
|
1475
1590
|
logError(` Tip: A required field is NULL. Check the entity's required fields.`);
|
|
@@ -1491,11 +1606,11 @@ export class PushService {
|
|
|
1491
1606
|
const displayValue = typeof value === 'string' && value.length > 100
|
|
1492
1607
|
? value.substring(0, 100) + '...'
|
|
1493
1608
|
: value;
|
|
1494
|
-
logError(` ${field.Name}: ${displayValue}`);
|
|
1609
|
+
logError(` ${field.Name}: ${envMask.NewValue(field.Name, String(displayValue))}`);
|
|
1495
1610
|
}
|
|
1496
1611
|
}
|
|
1497
1612
|
logError(''); // Empty line for readability
|
|
1498
|
-
throw saveError;
|
|
1613
|
+
throw MaskedError(saveError, errorTextMask);
|
|
1499
1614
|
}
|
|
1500
1615
|
if (!saveResult) {
|
|
1501
1616
|
// Helper to log to both console and callbacks
|
|
@@ -1504,14 +1619,14 @@ export class PushService {
|
|
|
1504
1619
|
callbacks?.onLog?.(msg);
|
|
1505
1620
|
};
|
|
1506
1621
|
logError(`\n❌ SAVE RETURNED FALSE for ${entityName}`);
|
|
1507
|
-
logError(` Record ID: ${
|
|
1508
|
-
logError(` Record Name: ${recordName
|
|
1622
|
+
logError(` Record ID: ${recordKeyText}`);
|
|
1623
|
+
logError(` Record Name: ${recordName ? envMask.NewValue('Name', String(recordName)) : 'N/A'}`);
|
|
1509
1624
|
logError(` File Path: ${flattenedRecord.path}`);
|
|
1510
1625
|
// Log the LatestResult for debugging
|
|
1511
1626
|
if (entity.LatestResult) {
|
|
1512
1627
|
const completeMessage = entity.LatestResult.CompleteMessage;
|
|
1513
1628
|
if (completeMessage) {
|
|
1514
|
-
logError(` Database Error: ${completeMessage}`);
|
|
1629
|
+
logError(` Database Error: ${errorTextMask.MaskText(completeMessage)}`);
|
|
1515
1630
|
}
|
|
1516
1631
|
if (entity.LatestResult.SQL) {
|
|
1517
1632
|
// Don't log the full SQL as it might be huge, just indicate it's available
|
|
@@ -1526,7 +1641,7 @@ export class PushService {
|
|
|
1526
1641
|
const displayValue = typeof value === 'string' && value.length > 100
|
|
1527
1642
|
? value.substring(0, 100) + '...'
|
|
1528
1643
|
: value;
|
|
1529
|
-
logError(` ${field.Name}: ${displayValue}`);
|
|
1644
|
+
logError(` ${field.Name}: ${envMask.NewValue(field.Name, String(displayValue))}`);
|
|
1530
1645
|
}
|
|
1531
1646
|
}
|
|
1532
1647
|
logError(''); // Empty line for readability
|
|
@@ -1538,17 +1653,18 @@ export class PushService {
|
|
|
1538
1653
|
if (entityInfo) {
|
|
1539
1654
|
for (const pk of entityInfo.PrimaryKeys) {
|
|
1540
1655
|
const pkValue = entity.Get(pk.Name);
|
|
1541
|
-
primaryKeyInfo.push(`${pk.Name}=${this.formatFieldValue(pkValue)}`);
|
|
1656
|
+
primaryKeyInfo.push(`${pk.Name}=${envMask.NewValue(pk.Name, this.formatFieldValue(pkValue))}`);
|
|
1542
1657
|
}
|
|
1543
1658
|
}
|
|
1544
1659
|
// Collect field values that were being saved
|
|
1545
1660
|
for (const [fieldName, fieldValue] of Object.entries(record.fields)) {
|
|
1546
1661
|
const processedValue = entity.Get(fieldName);
|
|
1547
|
-
|
|
1662
|
+
const envName = entity.GetFieldByName(fieldName)?.Name ?? fieldName;
|
|
1663
|
+
fieldInfo.push(`${fieldName}=${envMask.NewValue(envName, this.formatFieldValue(processedValue))}`);
|
|
1548
1664
|
}
|
|
1549
1665
|
// Get the actual error details from the entity
|
|
1550
|
-
const errorMessage = entity.LatestResult?.CompleteMessage || 'Unknown error';
|
|
1551
|
-
const errorDetails = entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '';
|
|
1666
|
+
const errorMessage = errorTextMask.MaskText(entity.LatestResult?.CompleteMessage || 'Unknown error');
|
|
1667
|
+
const errorDetails = errorTextMask.MaskText(entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '');
|
|
1552
1668
|
// Log detailed error information
|
|
1553
1669
|
callbacks?.onError?.(`\n❌ FATAL ERROR: Failed to save ${entityName} record`);
|
|
1554
1670
|
callbacks?.onError?.(` Entity: ${entityName}`);
|
|
@@ -1597,7 +1713,8 @@ export class PushService {
|
|
|
1597
1713
|
deferredRecord = {
|
|
1598
1714
|
flattenedRecord,
|
|
1599
1715
|
entityDir,
|
|
1600
|
-
entityConfig
|
|
1716
|
+
entityConfig,
|
|
1717
|
+
batchContext
|
|
1601
1718
|
};
|
|
1602
1719
|
if (options.verbose) {
|
|
1603
1720
|
callbacks?.onLog?.(` 📋 Queued ${entityName} for deferred processing (record saved, some fields pending)`);
|
|
@@ -1611,9 +1728,10 @@ export class PushService {
|
|
|
1611
1728
|
if (entityInfo) {
|
|
1612
1729
|
const newPrimaryKey = {};
|
|
1613
1730
|
const primaryKeyDisplay = [];
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1731
|
+
// An env-sourced key keeps the file's reference unless the database assigned another value
|
|
1732
|
+
for (const key of CreatedRecordKeyFields(entityInfo.PrimaryKeys, entity, keyBeforeSave, record.primaryKey, originalFields, envMask)) {
|
|
1733
|
+
newPrimaryKey[key.Name] = key.FileValue;
|
|
1734
|
+
primaryKeyDisplay.push(`${key.Name}: ${key.KeptBySave ? envMask.NewValue(key.Name, String(key.SavedValue)) : String(key.SavedValue)}`);
|
|
1617
1735
|
}
|
|
1618
1736
|
record.primaryKey = newPrimaryKey;
|
|
1619
1737
|
// Capture the create for the changes recap (no field diffs — the whole record is new).
|
|
@@ -1631,6 +1749,12 @@ export class PushService {
|
|
|
1631
1749
|
if (!shouldWriteSync) {
|
|
1632
1750
|
delete record.sync;
|
|
1633
1751
|
}
|
|
1752
|
+
else if (deferredRecord) {
|
|
1753
|
+
// A deferred record keeps its earlier checksum in the file, so each later --incremental push checks it again until the record changes
|
|
1754
|
+
if (options.verbose) {
|
|
1755
|
+
callbacks?.onLog?.(` - Sync metadata waits for the deferred fields`);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1634
1758
|
else if (isNew || isDirty) {
|
|
1635
1759
|
const checksumPayload = this.buildRecordChecksumPayload(record, originalFields);
|
|
1636
1760
|
const checksum = await this.syncEngine.calculateChecksumWithFileContent(checksumPayload, entityDir);
|
|
@@ -1695,6 +1819,77 @@ export class PushService {
|
|
|
1695
1819
|
status: isNew ? 'created' : (isDirty ? 'updated' : 'unchanged'),
|
|
1696
1820
|
};
|
|
1697
1821
|
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Resolves each primary-key value (`@lookup`, `@parent`, `@env`, …). A key lookup cannot be deferred.
|
|
1824
|
+
* With `referencesOnly`, only `@env:`, `@parent:` and `@owner:` values are resolved; others are copied as is.
|
|
1825
|
+
*/
|
|
1826
|
+
async resolvePrimaryKey(primaryKey, entityDir, parentEntity, batchContext, resolutionCollector, recordProvider, referencesOnly = false) {
|
|
1827
|
+
const resolved = {};
|
|
1828
|
+
for (const [pkField, pkValue] of Object.entries(primaryKey)) {
|
|
1829
|
+
if (referencesOnly && !IsKeyReference(pkValue)) {
|
|
1830
|
+
resolved[pkField] = pkValue;
|
|
1831
|
+
continue;
|
|
1832
|
+
}
|
|
1833
|
+
try {
|
|
1834
|
+
resolved[pkField] = await this.syncEngine.processFieldValue(pkValue, entityDir, parentEntity, null, // rootRecord
|
|
1835
|
+
0, batchContext, resolutionCollector, pkField, recordProvider);
|
|
1836
|
+
}
|
|
1837
|
+
catch (pkError) {
|
|
1838
|
+
if (pkError instanceof EnvVariableNotSetError) {
|
|
1839
|
+
throw pkError.ForField(`primaryKey.${pkField}`);
|
|
1840
|
+
}
|
|
1841
|
+
if (pkError instanceof DeferrableLookupError) {
|
|
1842
|
+
throw new Error(`Cannot defer lookup in primaryKey field '${pkField}': ${pkError.message}. Primary key lookups must resolve immediately.`);
|
|
1843
|
+
}
|
|
1844
|
+
throw pkError;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return resolved;
|
|
1848
|
+
}
|
|
1849
|
+
/** A file key value for printing: `<from @env:VAR>` for an `@env:` reference, else the value. */
|
|
1850
|
+
fileKeyText(value) {
|
|
1851
|
+
const reference = ParseEnvReference(value);
|
|
1852
|
+
return reference ? EnvSourceLabel([reference.VariableName]) : String(value);
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* The key with each `@env:` value resolved through `collector`, which records the env-sourced fields.
|
|
1856
|
+
* Other values are copied as written. A malformed or unset reference throws a names-only error.
|
|
1857
|
+
*/
|
|
1858
|
+
async resolveEnvKeyValues(primaryKey, entityDir, collector) {
|
|
1859
|
+
const resolved = {};
|
|
1860
|
+
for (const [field, value] of Object.entries(primaryKey)) {
|
|
1861
|
+
resolved[field] = typeof value === 'string' && value.startsWith(METADATA_KEYWORDS.ENV)
|
|
1862
|
+
? await this.syncEngine.processFieldValue(value, entityDir, null, null, 0, undefined, collector, field)
|
|
1863
|
+
: value;
|
|
1864
|
+
}
|
|
1865
|
+
return resolved;
|
|
1866
|
+
}
|
|
1867
|
+
/** True when the child holds every key value; UUID key fields match without regard to case. */
|
|
1868
|
+
childHasKey(child, key) {
|
|
1869
|
+
return Object.entries(key).every(([field, value]) => {
|
|
1870
|
+
const current = child.Get(field);
|
|
1871
|
+
const isUuidField = child.GetFieldByName(field)?.EntityFieldInfo?.IsUniqueIdentifier === true;
|
|
1872
|
+
if (isUuidField && typeof current === 'string' && typeof value === 'string') {
|
|
1873
|
+
return UUIDsEqual(current, value);
|
|
1874
|
+
}
|
|
1875
|
+
return String(current) === String(value);
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* The record's key for save-failure output: the value of a single key field, or `Field=value` pairs
|
|
1880
|
+
* for a composite key, with env-sourced key fields shown as labels. `NEW` when no key field has a value.
|
|
1881
|
+
*/
|
|
1882
|
+
recordIdLabel(entity, envMask) {
|
|
1883
|
+
const keyFields = entity.PrimaryKeys.filter((pk) => {
|
|
1884
|
+
const value = entity.Get(pk.Name);
|
|
1885
|
+
return value !== null && value !== undefined && value !== '';
|
|
1886
|
+
});
|
|
1887
|
+
if (keyFields.length === 0) {
|
|
1888
|
+
return 'NEW';
|
|
1889
|
+
}
|
|
1890
|
+
const labels = keyFields.map((pk) => envMask.NewValue(pk.Name, String(entity.Get(pk.Name))));
|
|
1891
|
+
return keyFields.length === 1 ? labels[0] : keyFields.map((pk, i) => `${pk.Name}=${labels[i]}`).join(', ');
|
|
1892
|
+
}
|
|
1698
1893
|
formatFieldValue(value) {
|
|
1699
1894
|
if (value === null || value === undefined)
|
|
1700
1895
|
return 'null';
|
|
@@ -1711,14 +1906,19 @@ export class PushService {
|
|
|
1711
1906
|
}
|
|
1712
1907
|
return String(value);
|
|
1713
1908
|
}
|
|
1714
|
-
async processDeleteRecord(flattenedRecord,
|
|
1909
|
+
async processDeleteRecord(flattenedRecord, entityDir, options, callbacks, isDbOnly = false) {
|
|
1715
1910
|
const { record, entityName } = flattenedRecord;
|
|
1716
1911
|
// Validate that we have a primary key for deletion
|
|
1717
1912
|
if (!record.primaryKey || Object.keys(record.primaryKey).length === 0) {
|
|
1718
1913
|
throw new Error(`Cannot delete ${entityName} record without primaryKey. Please specify primaryKey fields.`);
|
|
1719
1914
|
}
|
|
1720
|
-
// Load the entity to check if it exists in the target database
|
|
1721
|
-
const
|
|
1915
|
+
// Load the entity to check if it exists in the target database; an `@env:` key value is resolved first
|
|
1916
|
+
const keyCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map() };
|
|
1917
|
+
const resolvedKey = await this.resolveEnvKeyValues(record.primaryKey, entityDir, keyCollector);
|
|
1918
|
+
const keyMask = new EnvDisplayMask(keyCollector.EnvFields);
|
|
1919
|
+
// Read through the push transaction's provider, not the preload cache: a row an earlier delete's
|
|
1920
|
+
// cascade removed in this push is still in the cache until commit (#4935).
|
|
1921
|
+
const existingEntity = await this.syncEngine.LoadEntityFromDatabase(entityName, resolvedKey, this.transactionProvider());
|
|
1722
1922
|
// Check if the deletion has already been processed
|
|
1723
1923
|
if (record.deleteRecord?.deletedAt) {
|
|
1724
1924
|
// Verify if record still exists in THIS database
|
|
@@ -1736,26 +1936,16 @@ export class PushService {
|
|
|
1736
1936
|
// Fall through to deletion logic
|
|
1737
1937
|
}
|
|
1738
1938
|
if (!existingEntity) {
|
|
1739
|
-
|
|
1740
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
1741
|
-
.join(', ');
|
|
1742
|
-
const warning = `Record not found for deletion: ${entityName} with primaryKey {${pkDisplay}}`;
|
|
1743
|
-
this.warnings.push(warning);
|
|
1744
|
-
callbacks?.onWarn?.(warning);
|
|
1745
|
-
// Mark as deleted anyway since it doesn't exist
|
|
1746
|
-
if (!record.deleteRecord) {
|
|
1747
|
-
record.deleteRecord = { delete: true };
|
|
1748
|
-
}
|
|
1749
|
-
record.deleteRecord.deletedAt = undefined; // Indicate it was not found
|
|
1750
|
-
record.deleteRecord.notFound = true;
|
|
1751
|
-
return { status: 'skipped', isDuplicate: false };
|
|
1939
|
+
return this.skipMissingDelete(flattenedRecord, 'Record not found for deletion', callbacks);
|
|
1752
1940
|
}
|
|
1941
|
+
// Key values and free error text can hold a value of any variable push resolved
|
|
1942
|
+
const errorTextMask = EnvDisplayMask.ForVariables(this.syncEngine.ResolvedEnvVariables);
|
|
1753
1943
|
// Log the deletion
|
|
1754
1944
|
const entityInfo = this.syncEngine.getEntityInfo(entityName);
|
|
1755
1945
|
const primaryKeyDisplay = [];
|
|
1756
1946
|
if (entityInfo) {
|
|
1757
1947
|
for (const pk of entityInfo.PrimaryKeys) {
|
|
1758
|
-
primaryKeyDisplay.push(`${pk.Name}: ${existingEntity.Get(pk.Name)}`);
|
|
1948
|
+
primaryKeyDisplay.push(`${pk.Name}: ${keyMask.NewValue(pk.Name, errorTextMask.MaskText(String(existingEntity.Get(pk.Name))))}`);
|
|
1759
1949
|
}
|
|
1760
1950
|
}
|
|
1761
1951
|
// Deletion detail streams inline only in verbose mode — the recap is the default
|
|
@@ -1772,7 +1962,9 @@ export class PushService {
|
|
|
1772
1962
|
}
|
|
1773
1963
|
const recordNameVerbose = existingEntity.Get('Name');
|
|
1774
1964
|
if (recordNameVerbose) {
|
|
1775
|
-
|
|
1965
|
+
const fileNameKey = Object.keys(record.fields ?? {}).find((key) => key.toLowerCase() === 'name');
|
|
1966
|
+
const nameFromEnv = fileNameKey !== undefined && ParseEnvReference(record.fields?.[fileNameKey]) !== null;
|
|
1967
|
+
callbacks?.onLog?.(` Name: ${nameFromEnv ? ENV_HIDDEN_VALUE : recordNameVerbose}`);
|
|
1776
1968
|
}
|
|
1777
1969
|
}
|
|
1778
1970
|
// Capture the deletion for the changes recap (no field diffs for deletes).
|
|
@@ -1788,13 +1980,19 @@ export class PushService {
|
|
|
1788
1980
|
}
|
|
1789
1981
|
return { status: 'deleted', isDuplicate: false };
|
|
1790
1982
|
}
|
|
1983
|
+
// An env-sourced delete key gets the names-only SQL log warning
|
|
1984
|
+
this.registerEnvSqlFields(existingEntity, keyCollector.EnvFields, false);
|
|
1791
1985
|
// Delete the record
|
|
1792
1986
|
try {
|
|
1793
1987
|
const deleteResult = await existingEntity.Delete();
|
|
1794
1988
|
if (!deleteResult) {
|
|
1989
|
+
// A row already gone (removed by an earlier delete's cascade) is the intended end state
|
|
1990
|
+
if (!(await this.rowStillExists(entityName, resolvedKey))) {
|
|
1991
|
+
return this.skipMissingDelete(flattenedRecord, 'Record was already gone when it was deleted (an earlier delete in this push may have cascaded to it)', callbacks);
|
|
1992
|
+
}
|
|
1795
1993
|
// Check the LatestResult for error details
|
|
1796
|
-
const errorMessage = existingEntity.LatestResult?.CompleteMessage || 'Unknown error';
|
|
1797
|
-
const errorDetails = existingEntity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '';
|
|
1994
|
+
const errorMessage = errorTextMask.MaskText(existingEntity.LatestResult?.CompleteMessage || 'Unknown error');
|
|
1995
|
+
const errorDetails = errorTextMask.MaskText(existingEntity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '');
|
|
1798
1996
|
callbacks?.onError?.(`\n❌ Failed to delete ${entityName} record`);
|
|
1799
1997
|
callbacks?.onError?.(` Primary Key: {${primaryKeyDisplay.join(', ')}}`);
|
|
1800
1998
|
callbacks?.onError?.(` Error: ${errorMessage}`);
|
|
@@ -1810,7 +2008,7 @@ export class PushService {
|
|
|
1810
2008
|
throw new Error(`Failed to delete ${entityName} record: ${errorMessage}`);
|
|
1811
2009
|
}
|
|
1812
2010
|
// Remove from metadata cache
|
|
1813
|
-
this.syncMetadataEngine.removeEntityFromCache(entityName,
|
|
2011
|
+
this.syncMetadataEngine.removeEntityFromCache(entityName, resolvedKey);
|
|
1814
2012
|
// Set deletedAt timestamp after successful deletion (only for metadata records)
|
|
1815
2013
|
if (!isDbOnly) {
|
|
1816
2014
|
if (!record.deleteRecord) {
|
|
@@ -1831,9 +2029,12 @@ export class PushService {
|
|
|
1831
2029
|
return { status: 'deleted', isDuplicate: false };
|
|
1832
2030
|
}
|
|
1833
2031
|
catch (deleteError) {
|
|
2032
|
+
if (!(await this.rowStillExists(entityName, resolvedKey))) {
|
|
2033
|
+
return this.skipMissingDelete(flattenedRecord, 'Record was already gone when it was deleted (an earlier delete in this push may have cascaded to it)', callbacks);
|
|
2034
|
+
}
|
|
1834
2035
|
console.error(`\n❌ DELETE EXCEPTION for ${entityName}`);
|
|
1835
2036
|
console.error(` Primary Key: {${primaryKeyDisplay.join(', ')}}`);
|
|
1836
|
-
console.error(` Error: ${deleteError.message || deleteError}`);
|
|
2037
|
+
console.error(` Error: ${errorTextMask.MaskText(String(deleteError.message || deleteError))}`);
|
|
1837
2038
|
// Check for specific error patterns
|
|
1838
2039
|
if (deleteError.message?.includes('FOREIGN KEY constraint')) {
|
|
1839
2040
|
console.error(` Tip: This record is referenced by other records and cannot be deleted.`);
|
|
@@ -1842,9 +2043,64 @@ export class PushService {
|
|
|
1842
2043
|
else if (deleteError.message?.includes('permission')) {
|
|
1843
2044
|
console.error(` Tip: You may not have permission to delete this record.`);
|
|
1844
2045
|
}
|
|
1845
|
-
throw deleteError;
|
|
2046
|
+
throw MaskedError(deleteError, errorTextMask);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* A delete whose row is not in the database: a warning, the record marked not found, and a skip.
|
|
2051
|
+
* The database is already in the state the delete asks for.
|
|
2052
|
+
*/
|
|
2053
|
+
skipMissingDelete(flattenedRecord, reason, callbacks) {
|
|
2054
|
+
const { record, entityName } = flattenedRecord;
|
|
2055
|
+
const pkDisplay = Object.entries(record.primaryKey ?? {})
|
|
2056
|
+
.map(([key, value]) => `${key}=${this.fileKeyText(value)}`)
|
|
2057
|
+
.join(', ');
|
|
2058
|
+
const warning = `${reason}: ${entityName} with primaryKey {${pkDisplay}}`;
|
|
2059
|
+
this.warnings.push(warning);
|
|
2060
|
+
callbacks?.onWarn?.(warning);
|
|
2061
|
+
// Mark as deleted anyway since it doesn't exist
|
|
2062
|
+
if (!record.deleteRecord) {
|
|
2063
|
+
record.deleteRecord = { delete: true };
|
|
2064
|
+
}
|
|
2065
|
+
record.deleteRecord.deletedAt = undefined; // Indicate it was not found
|
|
2066
|
+
record.deleteRecord.notFound = true;
|
|
2067
|
+
return { status: 'skipped', isDuplicate: false };
|
|
2068
|
+
}
|
|
2069
|
+
/**
|
|
2070
|
+
* True when the row is still in the database, read through the push transaction. A failed read
|
|
2071
|
+
* counts as present, so the original delete error is reported.
|
|
2072
|
+
*/
|
|
2073
|
+
async rowStillExists(entityName, primaryKey) {
|
|
2074
|
+
try {
|
|
2075
|
+
return (await this.syncEngine.LoadEntityFromDatabase(entityName, primaryKey, this.transactionProvider())) !== null;
|
|
2076
|
+
}
|
|
2077
|
+
catch {
|
|
2078
|
+
return true;
|
|
1846
2079
|
}
|
|
1847
2080
|
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Throws when creating `entity` with the key the record declares would collide on a unique key
|
|
2083
|
+
* with an existing row, naming that row's key. See natural-key-preflight.ts.
|
|
2084
|
+
*/
|
|
2085
|
+
async checkNaturalKey(entity, resolvedPrimaryKey, envMask, recordProvider) {
|
|
2086
|
+
const host = this.hostProvider();
|
|
2087
|
+
if (typeof host?.ExecuteSQL !== 'function') {
|
|
2088
|
+
return; // no SQL provider, so no catalog to read unique keys from
|
|
2089
|
+
}
|
|
2090
|
+
// Rows are found on the record's provider, so the lookup sees what its transaction wrote
|
|
2091
|
+
const reader = new DatabaseNaturalKeyReader(host, this.contextUser, recordProvider ?? this.transactionProvider(), this.uniqueKeyCache);
|
|
2092
|
+
const conflict = await new NaturalKeyPreflight(reader).Check(entity);
|
|
2093
|
+
if (conflict) {
|
|
2094
|
+
const recordKey = Object.entries(resolvedPrimaryKey)
|
|
2095
|
+
.map(([field, value]) => `${field}=${envMask.NewValue(field, String(value))}`)
|
|
2096
|
+
.join(', ');
|
|
2097
|
+
throw new Error(NaturalKeyConflictMessage(entity.EntityInfo.Name, recordKey, conflict));
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
/** The provider that owns the push transaction, as the metadata provider a read joins it through. */
|
|
2101
|
+
transactionProvider() {
|
|
2102
|
+
return this.hostProvider();
|
|
2103
|
+
}
|
|
1848
2104
|
/**
|
|
1849
2105
|
* Prompt user for confirmation before proceeding with push operation
|
|
1850
2106
|
* This happens after deletion audit but before any database operations
|
|
@@ -1936,7 +2192,7 @@ export class PushService {
|
|
|
1936
2192
|
}
|
|
1937
2193
|
}
|
|
1938
2194
|
}
|
|
1939
|
-
const pattern = entityConfig.filePattern ||
|
|
2195
|
+
const pattern = entityConfig.filePattern || DEFAULT_RECORD_FILE_PATTERN;
|
|
1940
2196
|
const files = await fastGlob(pattern, {
|
|
1941
2197
|
cwd: entityDir,
|
|
1942
2198
|
absolute: true,
|
|
@@ -1987,7 +2243,7 @@ export class PushService {
|
|
|
1987
2243
|
continue;
|
|
1988
2244
|
}
|
|
1989
2245
|
// Find all JSON files
|
|
1990
|
-
const pattern = entityConfig.filePattern ||
|
|
2246
|
+
const pattern = entityConfig.filePattern || DEFAULT_RECORD_FILE_PATTERN;
|
|
1991
2247
|
const files = await fastGlob(pattern, {
|
|
1992
2248
|
cwd: entityDir,
|
|
1993
2249
|
absolute: true,
|
|
@@ -2010,6 +2266,9 @@ export class PushService {
|
|
|
2010
2266
|
const records = Array.isArray(fileData) ? fileData : [fileData];
|
|
2011
2267
|
// Phase 1: Flatten only - accumulates into analyzer's internal state
|
|
2012
2268
|
const flattenedRecords = analyzer.flattenFileRecords(records, entityConfig.entity);
|
|
2269
|
+
for (const flattened of flattenedRecords) {
|
|
2270
|
+
flattened.FilePath = filePath;
|
|
2271
|
+
}
|
|
2013
2272
|
allFlattenedRecords.push(...flattenedRecords);
|
|
2014
2273
|
}
|
|
2015
2274
|
catch (error) {
|
|
@@ -2119,9 +2378,10 @@ export class PushService {
|
|
|
2119
2378
|
}
|
|
2120
2379
|
}
|
|
2121
2380
|
catch (error) {
|
|
2122
|
-
|
|
2381
|
+
const located = error instanceof Error ? this.locateRecordError(error, record) : error;
|
|
2382
|
+
callbacks?.onError?.(` Failed to delete ${record.entityName}: ${located}`);
|
|
2123
2383
|
errors++;
|
|
2124
|
-
throw
|
|
2384
|
+
throw located; // Fail fast on first deletion error
|
|
2125
2385
|
}
|
|
2126
2386
|
}
|
|
2127
2387
|
}
|
|
@@ -2173,14 +2433,14 @@ export class PushService {
|
|
|
2173
2433
|
let created = 0;
|
|
2174
2434
|
let updated = 0;
|
|
2175
2435
|
let errors = 0;
|
|
2176
|
-
//
|
|
2177
|
-
|
|
2178
|
-
const batchContext = new BatchContextIndex();
|
|
2436
|
+
// A deferred record without the batch context of its main pass gets one for this phase
|
|
2437
|
+
const phaseBatchContext = new BatchContextIndex();
|
|
2179
2438
|
for (const deferred of this.deferredRecords) {
|
|
2180
2439
|
const { flattenedRecord, entityDir, entityConfig } = deferred;
|
|
2440
|
+
const batchContext = deferred.batchContext ?? phaseBatchContext;
|
|
2181
2441
|
const entityName = flattenedRecord.entityName;
|
|
2182
2442
|
const recordId = flattenedRecord.record.primaryKey
|
|
2183
|
-
? Object.entries(flattenedRecord.record.primaryKey).map(([k, v]) => `${k}=${v}`).join(', ')
|
|
2443
|
+
? Object.entries(flattenedRecord.record.primaryKey).map(([k, v]) => `${k}=${this.fileKeyText(v)}`).join(', ')
|
|
2184
2444
|
: (flattenedRecord.record.fields.Name || 'NEW');
|
|
2185
2445
|
try {
|
|
2186
2446
|
// Re-run processFlattenedRecord with allowDefer=false
|
|
@@ -2208,7 +2468,7 @@ export class PushService {
|
|
|
2208
2468
|
}
|
|
2209
2469
|
}
|
|
2210
2470
|
catch (error) {
|
|
2211
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
2471
|
+
const err = this.locateRecordError(error instanceof Error ? error : new Error(String(error)), flattenedRecord);
|
|
2212
2472
|
callbacks?.onError?.(` ✗ Failed to process deferred record: ${entityName} (${recordId})`);
|
|
2213
2473
|
callbacks?.onError?.(` Error: ${err.message}`);
|
|
2214
2474
|
callbacks?.onError?.(` Tip: Ensure all referenced records exist or remove the ?allowDefer flag`);
|
|
@@ -2359,6 +2619,89 @@ export class PushService {
|
|
|
2359
2619
|
buildRecordChecksumPayload(record, fieldsOverride) {
|
|
2360
2620
|
return SyncEngine.BuildRecordChecksumPayload(fieldsOverride ?? record.fields, record.collections, record.embeds, record.extension);
|
|
2361
2621
|
}
|
|
2622
|
+
/**
|
|
2623
|
+
* Resolves one field of an embed, collection item or extension and sets it on the target.
|
|
2624
|
+
* A missing `@env:VAR?optional` variable leaves the field unset and raises a warning.
|
|
2625
|
+
* `sharesOwnerFields` (IS-A extension children) records env-sourced fields in the owner's collector.
|
|
2626
|
+
*/
|
|
2627
|
+
async setCompositionField(target, fieldName, rawValue, entityDir, batchContext, resolutionCollector, recordProvider, ownerRecord, callbacks, sharesOwnerFields = false) {
|
|
2628
|
+
// An IS-A extension child writes owner fields, so it records into the owner's collector. Other
|
|
2629
|
+
// children get their own EnvFields map, so a child field never masks an owner field of the same name.
|
|
2630
|
+
const childCollector = sharesOwnerFields
|
|
2631
|
+
? resolutionCollector
|
|
2632
|
+
: { notes: resolutionCollector.notes, fieldPrefix: resolutionCollector.fieldPrefix, EnvFields: new Map() };
|
|
2633
|
+
try {
|
|
2634
|
+
const value = await this.syncEngine.processFieldValue(rawValue, entityDir, null, null, 0, batchContext, childCollector, fieldName, recordProvider, ownerRecord);
|
|
2635
|
+
target.Set(fieldName, value);
|
|
2636
|
+
const variables = childCollector.EnvFields?.get(fieldName);
|
|
2637
|
+
if (variables) {
|
|
2638
|
+
this.queueEnvSqlChildField(resolutionCollector, { Target: target, FieldName: fieldName, Variables: variables });
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
catch (error) {
|
|
2642
|
+
if (error instanceof EnvVariableNotSetError) {
|
|
2643
|
+
throw error.ForField(`fields.${fieldName}`, target.EntityInfo.Name);
|
|
2644
|
+
}
|
|
2645
|
+
if (!(error instanceof OptionalEnvMissingError)) {
|
|
2646
|
+
throw error;
|
|
2647
|
+
}
|
|
2648
|
+
this.addWarning(OptionalEnvSkipMessage(target.EntityInfo.Name, fieldName, error.VariableName), callbacks);
|
|
2649
|
+
this.queueEnvSqlChildField(resolutionCollector, { Target: target, FieldName: fieldName, Variables: new Set([error.VariableName]) });
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
/** Runs composition work and names its path (and entity) in an unset-variable error. */
|
|
2653
|
+
async withCompositionPath(compositionPath, entityName, work) {
|
|
2654
|
+
try {
|
|
2655
|
+
await work();
|
|
2656
|
+
}
|
|
2657
|
+
catch (error) {
|
|
2658
|
+
if (error instanceof EnvVariableNotSetError) {
|
|
2659
|
+
throw error.Within(compositionPath, entityName());
|
|
2660
|
+
}
|
|
2661
|
+
throw error;
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
/** Registers the env-sourced fields of this save with the SQL log redactor, when a log is open. */
|
|
2665
|
+
registerEnvSqlFields(entity, envFields, fullRow) {
|
|
2666
|
+
this.envSqlLog?.Redactor.RegisterEntityFields(entity, envFields, fullRow);
|
|
2667
|
+
}
|
|
2668
|
+
/** Keeps an env-sourced composition child field until its owner's save, when a SQL log is open. */
|
|
2669
|
+
queueEnvSqlChildField(ownerCollector, field) {
|
|
2670
|
+
if (!this.envSqlLog) {
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
const pending = this.pendingEnvSqlChildFields.get(ownerCollector) ?? [];
|
|
2674
|
+
pending.push(field);
|
|
2675
|
+
this.pendingEnvSqlChildFields.set(ownerCollector, pending);
|
|
2676
|
+
}
|
|
2677
|
+
/** Queues each env-sourced key field of a composition child for the names-only SQL log warning. */
|
|
2678
|
+
queueEnvSqlChildKey(ownerCollector, child, keyCollector) {
|
|
2679
|
+
for (const [fieldName, variables] of keyCollector.EnvFields ?? []) {
|
|
2680
|
+
this.queueEnvSqlChildField(ownerCollector, { Target: child, FieldName: fieldName, Variables: variables });
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
/** Registers the queued composition child fields of the record that is about to be saved. */
|
|
2684
|
+
registerPendingEnvSqlChildFields(ownerCollector) {
|
|
2685
|
+
const pending = this.pendingEnvSqlChildFields.get(ownerCollector) ?? [];
|
|
2686
|
+
this.pendingEnvSqlChildFields.delete(ownerCollector);
|
|
2687
|
+
for (const field of pending) {
|
|
2688
|
+
this.registerEnvSqlCompositionField(field.Target, field.FieldName, field.Variables);
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
/** Registers one env-sourced composition child field when its save statement holds it. */
|
|
2692
|
+
registerEnvSqlCompositionField(target, fieldName, variables) {
|
|
2693
|
+
const fullRow = !target.IsSaved || this.syncConfig?.push?.alwaysPush === true;
|
|
2694
|
+
this.registerEnvSqlFields(target, new Map([[fieldName, variables]]), fullRow);
|
|
2695
|
+
}
|
|
2696
|
+
/** Adds the names-only SQL log warnings of this run. */
|
|
2697
|
+
addEnvSqlWarnings(sqlLogPath, callbacks) {
|
|
2698
|
+
for (const warning of this.envSqlLog?.Redactor.Warnings(sqlLogPath) ?? []) {
|
|
2699
|
+
this.addWarning(warning, callbacks);
|
|
2700
|
+
}
|
|
2701
|
+
for (const warning of this.lookupSqlLog?.Rewriter.Warnings() ?? []) {
|
|
2702
|
+
this.addWarning(warning, callbacks);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2362
2705
|
/**
|
|
2363
2706
|
* Applies first-class composition axes onto the entity before Save (§5):
|
|
2364
2707
|
* 1. embeds (peer first via {fkField}_EnsureObject() or companion.Ensure())
|
|
@@ -2405,16 +2748,17 @@ export class PushService {
|
|
|
2405
2748
|
if (!embeddedEntity) {
|
|
2406
2749
|
throw new Error(`Failed to resolve embedded companion for '${fkField}' on entity '${entityName}'. Ensure that DeclareEmbeddedRecord is called for '${fkField}'.`);
|
|
2407
2750
|
}
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
)
|
|
2413
|
-
|
|
2751
|
+
const embedded = embeddedEntity;
|
|
2752
|
+
await this.withCompositionPath(`embeds.${fkField}`, () => embedded.EntityInfo.Name, async () => {
|
|
2753
|
+
// Apply embed fields, passing ownerRecord = entity for @owner: resolution
|
|
2754
|
+
if (embedRecord.fields && typeof embedRecord.fields === 'object') {
|
|
2755
|
+
for (const [fName, fVal] of Object.entries(embedRecord.fields)) {
|
|
2756
|
+
await this.setCompositionField(embedded, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks);
|
|
2757
|
+
}
|
|
2414
2758
|
}
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2759
|
+
// Recursively apply nested composition on the embedded record
|
|
2760
|
+
await this.applyCompositionAxes(embedded, embedRecord, embedded.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
|
|
2761
|
+
});
|
|
2418
2762
|
}
|
|
2419
2763
|
}
|
|
2420
2764
|
// 2. Collections: owner.Lines.Create() (or load existing by PK into the collection), recurse apply on child entity
|
|
@@ -2478,50 +2822,48 @@ export class PushService {
|
|
|
2478
2822
|
const matchedItemSet = new Set();
|
|
2479
2823
|
const colConfig = entityConfig?.collections?.[colName];
|
|
2480
2824
|
const mode = colConfig?.mode ?? 'upsert';
|
|
2481
|
-
for (const itemData of colItems) {
|
|
2825
|
+
for (const [itemIndex, itemData] of colItems.entries()) {
|
|
2482
2826
|
if (!itemData || typeof itemData !== 'object')
|
|
2483
2827
|
continue;
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2828
|
+
await this.withCompositionPath(`collections.${colName}[${itemIndex}]`, () => col.RelatedEntityName, async () => {
|
|
2829
|
+
let targetChild = null;
|
|
2830
|
+
// The child's key with `@env:` values resolved; env-sourced key fields are kept for the SQL log
|
|
2831
|
+
const childKeyCollector = { notes: resolutionCollector.notes, fieldPrefix: 'primaryKey', EnvFields: new Map() };
|
|
2832
|
+
const childKey = itemData.primaryKey
|
|
2833
|
+
? await this.resolveEnvKeyValues(itemData.primaryKey, entityDir, childKeyCollector)
|
|
2834
|
+
: undefined;
|
|
2835
|
+
// Match by primaryKey if available
|
|
2836
|
+
if (childKey && Object.keys(childKey).length > 0 && loadedItems.length > 0) {
|
|
2837
|
+
targetChild = loadedItems.find((child) => this.childHasKey(child, childKey)) ?? null;
|
|
2838
|
+
}
|
|
2839
|
+
if (itemData.deleteRecord?.delete === true) {
|
|
2840
|
+
// Explicit delete in both modes (§8.1(a) rider 1)
|
|
2841
|
+
if (targetChild) {
|
|
2842
|
+
this.queueEnvSqlChildKey(resolutionCollector, targetChild, childKeyCollector);
|
|
2843
|
+
col.Remove(targetChild);
|
|
2844
|
+
matchedItemSet.add(targetChild);
|
|
2492
2845
|
}
|
|
2493
|
-
return
|
|
2494
|
-
}) ?? null;
|
|
2495
|
-
}
|
|
2496
|
-
if (itemData.deleteRecord?.delete === true) {
|
|
2497
|
-
// Explicit delete in both modes (§8.1(a) rider 1)
|
|
2498
|
-
if (targetChild) {
|
|
2499
|
-
col.Remove(targetChild);
|
|
2500
|
-
matchedItemSet.add(targetChild);
|
|
2846
|
+
return;
|
|
2501
2847
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
targetChild = await col.Create();
|
|
2506
|
-
if (itemData.primaryKey) {
|
|
2507
|
-
for (const [pkK, pkV] of Object.entries(itemData.primaryKey)) {
|
|
2848
|
+
if (!targetChild) {
|
|
2849
|
+
targetChild = await col.Create();
|
|
2850
|
+
for (const [pkK, pkV] of Object.entries(childKey ?? {})) {
|
|
2508
2851
|
targetChild.Set(pkK, pkV);
|
|
2509
2852
|
}
|
|
2510
2853
|
}
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
matchedItemSet.add(targetChild);
|
|
2514
|
-
}
|
|
2515
|
-
// Apply fields with ownerRecord = entity for @owner:Field resolution
|
|
2516
|
-
if (itemData.fields && typeof itemData.fields === 'object') {
|
|
2517
|
-
for (const [fName, fVal] of Object.entries(itemData.fields)) {
|
|
2518
|
-
const processedValue = await this.syncEngine.processFieldValue(fVal, entityDir, null, null, 0, batchContext, resolutionCollector, fName, recordProvider, entity // ownerRecord
|
|
2519
|
-
);
|
|
2520
|
-
targetChild.Set(fName, processedValue);
|
|
2854
|
+
else {
|
|
2855
|
+
matchedItemSet.add(targetChild);
|
|
2521
2856
|
}
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2857
|
+
this.queueEnvSqlChildKey(resolutionCollector, targetChild, childKeyCollector);
|
|
2858
|
+
// Apply fields with ownerRecord = entity for @owner:Field resolution
|
|
2859
|
+
if (itemData.fields && typeof itemData.fields === 'object') {
|
|
2860
|
+
for (const [fName, fVal] of Object.entries(itemData.fields)) {
|
|
2861
|
+
await this.setCompositionField(targetChild, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks);
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
// Recursively apply nested composition on the collection item
|
|
2865
|
+
await this.applyCompositionAxes(targetChild, itemData, targetChild.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
|
|
2866
|
+
});
|
|
2525
2867
|
}
|
|
2526
2868
|
// Handle authoritative mode deletions (§8.1(a))
|
|
2527
2869
|
if (mode === 'authoritative' && loadedItems.length > 0) {
|
|
@@ -2570,13 +2912,13 @@ export class PushService {
|
|
|
2570
2912
|
throw new Error(`EnsureISAChild returned null for extension on '${entityName}'${subName ? ` (${subName})` : ''}. ` +
|
|
2571
2913
|
`Ensure that an EntitySubtypeResolver or Entity.SubtypeSelector is configured, or specify 'entity' in extension.`);
|
|
2572
2914
|
}
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2915
|
+
await this.withCompositionPath('extension', () => child.EntityInfo.Name, async () => {
|
|
2916
|
+
for (const [fName, fVal] of Object.entries(extObj.fields)) {
|
|
2917
|
+
await this.setCompositionField(child, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks, true);
|
|
2918
|
+
}
|
|
2919
|
+
// Recursively apply nested composition on the child extension
|
|
2920
|
+
await this.applyCompositionAxes(child, extObj, child.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
|
|
2921
|
+
});
|
|
2580
2922
|
}
|
|
2581
2923
|
else {
|
|
2582
2924
|
// Map form: { [SubtypeName]: { fields: { ... } } }
|
|
@@ -2589,16 +2931,16 @@ export class PushService {
|
|
|
2589
2931
|
if (!child) {
|
|
2590
2932
|
throw new Error(`EnsureISAChild returned null for extension subtype '${subKey}' on '${entityName}'.`);
|
|
2591
2933
|
}
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2934
|
+
await this.withCompositionPath(`extension.${subKey}`, () => child.EntityInfo.Name, async () => {
|
|
2935
|
+
const subRecord = subVal;
|
|
2936
|
+
if (subRecord.fields && typeof subRecord.fields === 'object') {
|
|
2937
|
+
for (const [fName, fVal] of Object.entries(subRecord.fields)) {
|
|
2938
|
+
await this.setCompositionField(child, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks, true);
|
|
2939
|
+
}
|
|
2598
2940
|
}
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2941
|
+
// Recursively apply nested composition on the child extension
|
|
2942
|
+
await this.applyCompositionAxes(child, subRecord, child.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
|
|
2943
|
+
});
|
|
2602
2944
|
}
|
|
2603
2945
|
}
|
|
2604
2946
|
}
|