@memberjunction/metadata-sync 6.2.0-edge.5 → 6.2.0-edge.7
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 +151 -2
- package/dist/config.d.ts +37 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/RecordProcessor.d.ts +16 -0
- package/dist/lib/RecordProcessor.d.ts.map +1 -1
- package/dist/lib/RecordProcessor.js +51 -4
- package/dist/lib/RecordProcessor.js.map +1 -1
- package/dist/lib/collection-promotion.d.ts +89 -0
- package/dist/lib/collection-promotion.d.ts.map +1 -0
- package/dist/lib/collection-promotion.js +154 -0
- package/dist/lib/collection-promotion.js.map +1 -0
- package/dist/lib/deletion-auditor.d.ts.map +1 -1
- package/dist/lib/deletion-auditor.js +1 -1
- package/dist/lib/deletion-auditor.js.map +1 -1
- package/dist/lib/deletion-prescan.d.ts +26 -0
- package/dist/lib/deletion-prescan.d.ts.map +1 -0
- package/dist/lib/deletion-prescan.js +56 -0
- package/dist/lib/deletion-prescan.js.map +1 -0
- package/dist/lib/env-sql-redactor.d.ts +1 -1
- package/dist/lib/env-sql-redactor.d.ts.map +1 -1
- package/dist/lib/env-sql-redactor.js +7 -72
- package/dist/lib/env-sql-redactor.js.map +1 -1
- package/dist/lib/lookup-sql.d.ts +109 -0
- package/dist/lib/lookup-sql.d.ts.map +1 -0
- package/dist/lib/lookup-sql.js +243 -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 +174 -0
- package/dist/lib/natural-key-preflight.js.map +1 -0
- package/dist/lib/record-dependency-analyzer.d.ts +14 -3
- package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
- package/dist/lib/record-dependency-analyzer.js +59 -72
- package/dist/lib/record-dependency-analyzer.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-log-filters.d.ts +31 -0
- package/dist/lib/sql-log-filters.d.ts.map +1 -0
- package/dist/lib/sql-log-filters.js +41 -0
- package/dist/lib/sql-log-filters.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 +14 -2
- package/dist/lib/sync-engine.d.ts.map +1 -1
- package/dist/lib/sync-engine.js +68 -9
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/services/PullService.d.ts +2 -0
- package/dist/services/PullService.d.ts.map +1 -1
- package/dist/services/PullService.js +20 -2
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.d.ts +42 -0
- package/dist/services/PushService.d.ts.map +1 -1
- package/dist/services/PushService.js +180 -58
- package/dist/services/PushService.js.map +1 -1
- package/dist/services/ValidationService.d.ts +31 -0
- package/dist/services/ValidationService.d.ts.map +1 -1
- package/dist/services/ValidationService.js +135 -9
- package/dist/services/ValidationService.js.map +1 -1
- package/dist/services/WatchService.d.ts +2 -0
- package/dist/services/WatchService.d.ts.map +1 -1
- package/dist/services/WatchService.js +19 -2
- package/dist/services/WatchService.js.map +1 -1
- package/package.json +15 -15
|
@@ -27,11 +27,17 @@ import { EnvVariableNotSetError, OptionalEnvMissingError, OptionalEnvSkipMessage
|
|
|
27
27
|
import { RecordStartLine } from '../lib/json-record-lines.js';
|
|
28
28
|
import { EnvDisplayMask, EnvSourceLabel, ENV_HIDDEN_VALUE, MaskedError } from '../lib/env-display.js';
|
|
29
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';
|
|
30
33
|
import { CreatedRecordKeyFields, IsKeyReference, KeyFieldValue } from '../lib/created-record-key.js';
|
|
31
34
|
import { EntityFieldName, RecordFieldNames } from '../lib/entity-field-names.js';
|
|
32
35
|
import { METADATA_KEYWORDS } from '../constants/metadata-keywords.js';
|
|
33
36
|
import { DEFAULT_RECORD_FILE_PATTERN, RECORD_FILE_GLOB_OPTIONS } from '../lib/record-file-glob.js';
|
|
37
|
+
import { FileMayHaveDeletions, RecordsHaveDeletions, TextHasInclude } from '../lib/deletion-prescan.js';
|
|
38
|
+
import { ResolveSqlLogFilter } from '../lib/sql-log-filters.js';
|
|
34
39
|
import { resolveCollectionRelationship } from '@memberjunction/record-graph';
|
|
40
|
+
import { CollectionPromotionResolver } from '../lib/collection-promotion.js';
|
|
35
41
|
function emptyPushTotals() {
|
|
36
42
|
return { created: 0, updated: 0, unchanged: 0, deleted: 0, skipped: 0, deferred: 0, errors: 0 };
|
|
37
43
|
}
|
|
@@ -65,6 +71,13 @@ export class PushService {
|
|
|
65
71
|
this.deferredFileWrites = new Map();
|
|
66
72
|
this.deferredRecords = [];
|
|
67
73
|
this.confirmedCollections = new Set();
|
|
74
|
+
/** Decides which relatedEntities groups are saved as collection items; created on first use. */
|
|
75
|
+
this.collectionPromotionResolver = null;
|
|
76
|
+
/**
|
|
77
|
+
* Promoted relatedEntities items created by a save, waiting for it to succeed so their new keys
|
|
78
|
+
* can be written back into the item, the same way a separately saved related record gets its key.
|
|
79
|
+
*/
|
|
80
|
+
this.pendingPromotedKeys = new Map();
|
|
68
81
|
/** Write mode per entity directory, resolved once at the start of the push. */
|
|
69
82
|
this.directoryModes = new Map();
|
|
70
83
|
/** The directory being processed right now, and how it writes. */
|
|
@@ -74,6 +87,10 @@ export class PushService {
|
|
|
74
87
|
this.runningTotals = emptyPushTotals();
|
|
75
88
|
/** The open SQL log's redactor and lookup-create hook; null when no log is open. */
|
|
76
89
|
this.envSqlLog = null;
|
|
90
|
+
/** The open SQL log's `@lookup` → subquery rewriter and lookup-create hook; null when no log is open. */
|
|
91
|
+
this.lookupSqlLog = null;
|
|
92
|
+
/** Unique keys of each entity's table read for the create pre-flight (#4400), by entity name. */
|
|
93
|
+
this.uniqueKeyCache = new Map();
|
|
77
94
|
/** Composition child fields waiting for their owner's save, by the owner record's resolution collector. */
|
|
78
95
|
this.pendingEnvSqlChildFields = new WeakMap();
|
|
79
96
|
/** Non-atomic mode: creates and updates that were committed outside the push transaction. */
|
|
@@ -370,17 +387,26 @@ export class PushService {
|
|
|
370
387
|
// Create the SQL logging session
|
|
371
388
|
this.sqlLogFilePath = filepath;
|
|
372
389
|
this.envSqlLog = new EnvSqlLog(this.syncEngine);
|
|
390
|
+
this.lookupSqlLog = new LookupSqlLog(this.syncEngine);
|
|
391
|
+
const schemaPlaceholders = ResolveSchemaPlaceholders(this.syncConfig, configManager.GetMJConfig());
|
|
392
|
+
const logFilter = ResolveSqlLogFilter(this.syncConfig?.sqlLogging);
|
|
373
393
|
sqlLoggingSession = await provider.CreateSqlLogger(filepath, {
|
|
374
|
-
|
|
394
|
+
// Env values are left out first, then lookup IDs become subqueries; the logger's schema
|
|
395
|
+
// placeholder rewrite runs after both, so the subqueries' schema names become placeholders.
|
|
396
|
+
TransformStatement: ComposeStatementTransforms(this.envSqlLog.Redactor.Transform, this.lookupSqlLog.Rewriter.Transform),
|
|
375
397
|
formatAsMigration: this.syncConfig?.sqlLogging?.formatAsMigration || false,
|
|
398
|
+
schemaPlaceholders,
|
|
399
|
+
// Escaping is tied to migration output, never to whether placeholder rules exist
|
|
400
|
+
escapeFlywaySyntax: this.syncConfig?.sqlLogging?.formatAsMigration === true || this.syncConfig?.sqlLogging?.escapeFlywaySyntax === true,
|
|
376
401
|
description: 'MetadataSync push operation',
|
|
377
402
|
statementTypes: "mutations",
|
|
378
403
|
prettyPrint: true,
|
|
379
404
|
// batchSeparator is intentionally omitted — CreateSqlLogger injects the platform-appropriate
|
|
380
405
|
// separator automatically (GO for SQL Server, nothing for PostgreSQL) via PlatformBatchSeparator.
|
|
381
|
-
filterPatterns:
|
|
382
|
-
filterType:
|
|
406
|
+
filterPatterns: logFilter.Patterns,
|
|
407
|
+
filterType: logFilter.FilterType,
|
|
383
408
|
verboseOutput: this.syncConfig?.sqlLogging?.verboseOutput || false,
|
|
409
|
+
variableBatchThreshold: this.syncConfig?.sqlLogging?.variableBatchThreshold ?? 200,
|
|
384
410
|
});
|
|
385
411
|
if (options.verbose) {
|
|
386
412
|
callbacks?.onLog?.(`📝 SQL logging enabled: ${filepath}`);
|
|
@@ -456,6 +482,8 @@ export class PushService {
|
|
|
456
482
|
closeEnvSqlRedactor() {
|
|
457
483
|
this.envSqlLog?.Close();
|
|
458
484
|
this.envSqlLog = null;
|
|
485
|
+
this.lookupSqlLog?.Close();
|
|
486
|
+
this.lookupSqlLog = null;
|
|
459
487
|
}
|
|
460
488
|
/**
|
|
461
489
|
* Decide, once and up front, how each entity directory writes: shared by default, isolated where
|
|
@@ -829,12 +857,13 @@ export class PushService {
|
|
|
829
857
|
// Keep unprocessed data to write back (preserves @file: references)
|
|
830
858
|
const unprocessedRecords = Array.isArray(rawFileData) ? rawFileData : [rawFileData];
|
|
831
859
|
const isArray = Array.isArray(rawFileData);
|
|
832
|
-
// Compute checksum on the RESOLVED content (after @include preprocessing)
|
|
833
|
-
// so
|
|
860
|
+
// Compute checksum on the RESOLVED content (after @include preprocessing), with the content
|
|
861
|
+
// of each @file: target folded in, so a change to an included or referenced file is
|
|
862
|
+
// detected (#4190). @file: paths resolve against the entity folder, as push resolves them.
|
|
834
863
|
let cachedChecksum;
|
|
835
864
|
if (options.incremental && this.stateManager && syncRootDir) {
|
|
836
865
|
const relativePath = path.relative(syncRootDir, filePath);
|
|
837
|
-
cachedChecksum = this.syncEngine.
|
|
866
|
+
cachedChecksum = await this.syncEngine.CalculateChecksumWithFileContent(fileData, entityDir);
|
|
838
867
|
if (!this.stateManager.hasFileChanged(relativePath, cachedChecksum)) {
|
|
839
868
|
const recordCount = Array.isArray(rawFileData) ? rawFileData.length : 1;
|
|
840
869
|
if (options.verbose) {
|
|
@@ -847,7 +876,9 @@ export class PushService {
|
|
|
847
876
|
const records = Array.isArray(fileData) ? fileData : [fileData];
|
|
848
877
|
// Analyze dependencies and get sorted records
|
|
849
878
|
const analyzer = new RecordDependencyAnalyzer();
|
|
850
|
-
const
|
|
879
|
+
const promotions = this.collectionPromotions();
|
|
880
|
+
await promotions.Prepare(records, entityConfig.entity);
|
|
881
|
+
const analysisResult = await analyzer.analyzeFileRecords(records, entityConfig.entity, promotions);
|
|
851
882
|
for (const flattened of analysisResult.sortedRecords) {
|
|
852
883
|
flattened.FilePath = filePath;
|
|
853
884
|
}
|
|
@@ -946,7 +977,7 @@ export class PushService {
|
|
|
946
977
|
// Uses resolved content (after @include) so included-file changes are tracked.
|
|
947
978
|
if (this.stateManager && syncRootDir) {
|
|
948
979
|
const relativePath = path.relative(syncRootDir, filePath);
|
|
949
|
-
const checksum = cachedChecksum ?? this.syncEngine.
|
|
980
|
+
const checksum = cachedChecksum ?? (await this.syncEngine.CalculateChecksumWithFileContent(fileData, entityDir));
|
|
950
981
|
this.pendingChecksums.set(relativePath, checksum);
|
|
951
982
|
}
|
|
952
983
|
}
|
|
@@ -1169,7 +1200,10 @@ export class PushService {
|
|
|
1169
1200
|
// silent no-ops when seeding a fresh DB from JSON files that already
|
|
1170
1201
|
// carry stored checksums from another environment.
|
|
1171
1202
|
// The deferred retry (allowDefer false) never takes this shortcut: the first pass left fields unsaved
|
|
1172
|
-
|
|
1203
|
+
// A record whose relatedEntities are saved as collection items never takes the shortcut: its
|
|
1204
|
+
// checksum does not cover those items, so an edit to one would be skipped.
|
|
1205
|
+
const hasPromotedGroups = this.collectionPromotions().PromotedGroups(entityName, record).length > 0;
|
|
1206
|
+
if (allowDefer && options.incremental && record.sync?.checksum && record.fields && record.primaryKey && !record.deleteRecord && !hasPromotedGroups) {
|
|
1173
1207
|
// Use calculateChecksumWithFileContent so @file: reference changes are detected.
|
|
1174
1208
|
// This reads local files (fast) rather than hitting the DB (slow). The stored
|
|
1175
1209
|
// checksum was also computed with file content and composition payloads, so the comparison is apples-to-apples.
|
|
@@ -1204,7 +1238,7 @@ export class PushService {
|
|
|
1204
1238
|
// Check if record exists
|
|
1205
1239
|
// Process primaryKey values through processFieldValue to resolve @lookup, @parent, etc.
|
|
1206
1240
|
// Create a resolution collector to track @lookup and @parent resolutions
|
|
1207
|
-
const resolutionCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map() };
|
|
1241
|
+
const resolutionCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map(), LookupSources: new Map() };
|
|
1208
1242
|
// Reads the same EnvFields map, so fields recorded later are masked too
|
|
1209
1243
|
const envMask = new EnvDisplayMask(resolutionCollector.EnvFields);
|
|
1210
1244
|
let resolvedPrimaryKey;
|
|
@@ -1500,6 +1534,11 @@ export class PushService {
|
|
|
1500
1534
|
status: 'unchanged',
|
|
1501
1535
|
};
|
|
1502
1536
|
}
|
|
1537
|
+
// A literal key that missed can still collide on a unique key with a row this database made
|
|
1538
|
+
// under another ID: report that row instead of a constraint violation (#4400).
|
|
1539
|
+
if (isNew && resolvedPrimaryKey && Object.keys(resolvedPrimaryKey).length > 0) {
|
|
1540
|
+
await this.checkNaturalKey(entity, resolvedPrimaryKey, envMask, recordProvider);
|
|
1541
|
+
}
|
|
1503
1542
|
// A dry run stops here, after the same change detection a real push uses, so an
|
|
1504
1543
|
// in-sync record reports unchanged above rather than "would update" (#4529).
|
|
1505
1544
|
if (options.dryRun) {
|
|
@@ -1531,6 +1570,8 @@ export class PushService {
|
|
|
1531
1570
|
}
|
|
1532
1571
|
this.registerEnvSqlFields(entity, resolutionCollector.EnvFields, isNew || alwaysPush);
|
|
1533
1572
|
this.registerPendingEnvSqlChildFields(resolutionCollector);
|
|
1573
|
+
// Key and foreign-key values from @lookup are logged as subqueries on the natural key (#5626)
|
|
1574
|
+
this.lookupSqlLog?.Rewriter.RegisterEntityLookups(entity, resolutionCollector.LookupSources);
|
|
1534
1575
|
// Save the record with detailed error logging
|
|
1535
1576
|
const keyBeforeSave = new Map(entity.PrimaryKeys.map((pk) => [pk.Name, entity.Get(pk.Name)]));
|
|
1536
1577
|
const recordName = entity.Get('Name');
|
|
@@ -1697,6 +1738,7 @@ export class PushService {
|
|
|
1697
1738
|
// Return 'deferred' status - it's saved but incomplete
|
|
1698
1739
|
// We don't return early here because we still want to update primaryKey and sync metadata
|
|
1699
1740
|
}
|
|
1741
|
+
this.writeBackPromotedKeys(record);
|
|
1700
1742
|
// Update primaryKey for new records
|
|
1701
1743
|
if (isNew) {
|
|
1702
1744
|
const entityInfo = this.syncEngine.getEntityInfo(entityName);
|
|
@@ -1891,7 +1933,9 @@ export class PushService {
|
|
|
1891
1933
|
const keyCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map() };
|
|
1892
1934
|
const resolvedKey = await this.resolveEnvKeyValues(record.primaryKey, entityDir, keyCollector);
|
|
1893
1935
|
const keyMask = new EnvDisplayMask(keyCollector.EnvFields);
|
|
1894
|
-
|
|
1936
|
+
// Read through the push transaction's provider, not the preload cache: a row an earlier delete's
|
|
1937
|
+
// cascade removed in this push is still in the cache until commit (#4935).
|
|
1938
|
+
const existingEntity = await this.syncEngine.LoadEntityFromDatabase(entityName, resolvedKey, this.transactionProvider());
|
|
1895
1939
|
// Check if the deletion has already been processed
|
|
1896
1940
|
if (record.deleteRecord?.deletedAt) {
|
|
1897
1941
|
// Verify if record still exists in THIS database
|
|
@@ -1909,19 +1953,7 @@ export class PushService {
|
|
|
1909
1953
|
// Fall through to deletion logic
|
|
1910
1954
|
}
|
|
1911
1955
|
if (!existingEntity) {
|
|
1912
|
-
|
|
1913
|
-
.map(([key, value]) => `${key}=${this.fileKeyText(value)}`)
|
|
1914
|
-
.join(', ');
|
|
1915
|
-
const warning = `Record not found for deletion: ${entityName} with primaryKey {${pkDisplay}}`;
|
|
1916
|
-
this.warnings.push(warning);
|
|
1917
|
-
callbacks?.onWarn?.(warning);
|
|
1918
|
-
// Mark as deleted anyway since it doesn't exist
|
|
1919
|
-
if (!record.deleteRecord) {
|
|
1920
|
-
record.deleteRecord = { delete: true };
|
|
1921
|
-
}
|
|
1922
|
-
record.deleteRecord.deletedAt = undefined; // Indicate it was not found
|
|
1923
|
-
record.deleteRecord.notFound = true;
|
|
1924
|
-
return { status: 'skipped', isDuplicate: false };
|
|
1956
|
+
return this.skipMissingDelete(flattenedRecord, 'Record not found for deletion', callbacks);
|
|
1925
1957
|
}
|
|
1926
1958
|
// Key values and free error text can hold a value of any variable push resolved
|
|
1927
1959
|
const errorTextMask = EnvDisplayMask.ForVariables(this.syncEngine.ResolvedEnvVariables);
|
|
@@ -1971,6 +2003,10 @@ export class PushService {
|
|
|
1971
2003
|
try {
|
|
1972
2004
|
const deleteResult = await existingEntity.Delete();
|
|
1973
2005
|
if (!deleteResult) {
|
|
2006
|
+
// A row already gone (removed by an earlier delete's cascade) is the intended end state
|
|
2007
|
+
if (!(await this.rowStillExists(entityName, resolvedKey))) {
|
|
2008
|
+
return this.skipMissingDelete(flattenedRecord, 'Record was already gone when it was deleted (an earlier delete in this push may have cascaded to it)', callbacks);
|
|
2009
|
+
}
|
|
1974
2010
|
// Check the LatestResult for error details
|
|
1975
2011
|
const errorMessage = errorTextMask.MaskText(existingEntity.LatestResult?.CompleteMessage || 'Unknown error');
|
|
1976
2012
|
const errorDetails = errorTextMask.MaskText(existingEntity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '');
|
|
@@ -2010,6 +2046,9 @@ export class PushService {
|
|
|
2010
2046
|
return { status: 'deleted', isDuplicate: false };
|
|
2011
2047
|
}
|
|
2012
2048
|
catch (deleteError) {
|
|
2049
|
+
if (!(await this.rowStillExists(entityName, resolvedKey))) {
|
|
2050
|
+
return this.skipMissingDelete(flattenedRecord, 'Record was already gone when it was deleted (an earlier delete in this push may have cascaded to it)', callbacks);
|
|
2051
|
+
}
|
|
2013
2052
|
console.error(`\n❌ DELETE EXCEPTION for ${entityName}`);
|
|
2014
2053
|
console.error(` Primary Key: {${primaryKeyDisplay.join(', ')}}`);
|
|
2015
2054
|
console.error(` Error: ${errorTextMask.MaskText(String(deleteError.message || deleteError))}`);
|
|
@@ -2024,6 +2063,61 @@ export class PushService {
|
|
|
2024
2063
|
throw MaskedError(deleteError, errorTextMask);
|
|
2025
2064
|
}
|
|
2026
2065
|
}
|
|
2066
|
+
/**
|
|
2067
|
+
* A delete whose row is not in the database: a warning, the record marked not found, and a skip.
|
|
2068
|
+
* The database is already in the state the delete asks for.
|
|
2069
|
+
*/
|
|
2070
|
+
skipMissingDelete(flattenedRecord, reason, callbacks) {
|
|
2071
|
+
const { record, entityName } = flattenedRecord;
|
|
2072
|
+
const pkDisplay = Object.entries(record.primaryKey ?? {})
|
|
2073
|
+
.map(([key, value]) => `${key}=${this.fileKeyText(value)}`)
|
|
2074
|
+
.join(', ');
|
|
2075
|
+
const warning = `${reason}: ${entityName} with primaryKey {${pkDisplay}}`;
|
|
2076
|
+
this.warnings.push(warning);
|
|
2077
|
+
callbacks?.onWarn?.(warning);
|
|
2078
|
+
// Mark as deleted anyway since it doesn't exist
|
|
2079
|
+
if (!record.deleteRecord) {
|
|
2080
|
+
record.deleteRecord = { delete: true };
|
|
2081
|
+
}
|
|
2082
|
+
record.deleteRecord.deletedAt = undefined; // Indicate it was not found
|
|
2083
|
+
record.deleteRecord.notFound = true;
|
|
2084
|
+
return { status: 'skipped', isDuplicate: false };
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* True when the row is still in the database, read through the push transaction. A failed read
|
|
2088
|
+
* counts as present, so the original delete error is reported.
|
|
2089
|
+
*/
|
|
2090
|
+
async rowStillExists(entityName, primaryKey) {
|
|
2091
|
+
try {
|
|
2092
|
+
return (await this.syncEngine.LoadEntityFromDatabase(entityName, primaryKey, this.transactionProvider())) !== null;
|
|
2093
|
+
}
|
|
2094
|
+
catch {
|
|
2095
|
+
return true;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* Throws when creating `entity` with the key the record declares would collide on a unique key
|
|
2100
|
+
* with an existing row, naming that row's key. See natural-key-preflight.ts.
|
|
2101
|
+
*/
|
|
2102
|
+
async checkNaturalKey(entity, resolvedPrimaryKey, envMask, recordProvider) {
|
|
2103
|
+
const host = this.hostProvider();
|
|
2104
|
+
if (typeof host?.ExecuteSQL !== 'function') {
|
|
2105
|
+
return; // no SQL provider, so no catalog to read unique keys from
|
|
2106
|
+
}
|
|
2107
|
+
// Rows are found on the record's provider, so the lookup sees what its transaction wrote
|
|
2108
|
+
const reader = new DatabaseNaturalKeyReader(host, this.contextUser, recordProvider ?? this.transactionProvider(), this.uniqueKeyCache);
|
|
2109
|
+
const conflict = await new NaturalKeyPreflight(reader).Check(entity);
|
|
2110
|
+
if (conflict) {
|
|
2111
|
+
const recordKey = Object.entries(resolvedPrimaryKey)
|
|
2112
|
+
.map(([field, value]) => `${field}=${envMask.NewValue(field, String(value))}`)
|
|
2113
|
+
.join(', ');
|
|
2114
|
+
throw new Error(NaturalKeyConflictMessage(entity.EntityInfo.Name, recordKey, conflict));
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
/** The provider that owns the push transaction, as the metadata provider a read joins it through. */
|
|
2118
|
+
transactionProvider() {
|
|
2119
|
+
return this.hostProvider();
|
|
2120
|
+
}
|
|
2027
2121
|
/**
|
|
2028
2122
|
* Prompt user for confirmation before proceeding with push operation
|
|
2029
2123
|
* This happens after deletion audit but before any database operations
|
|
@@ -2123,20 +2217,14 @@ export class PushService {
|
|
|
2123
2217
|
dot: true,
|
|
2124
2218
|
ignore: ['**/node_modules/**', '**/.mj-*.json']
|
|
2125
2219
|
});
|
|
2126
|
-
// Quick scan for delete
|
|
2220
|
+
// Quick scan for delete markers at any depth. A file that uses @include falls through to
|
|
2221
|
+
// the full audit, which expands includes before looking.
|
|
2127
2222
|
for (const filePath of files) {
|
|
2128
2223
|
try {
|
|
2129
2224
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
const data = JSON.parse(content);
|
|
2134
|
-
const records = Array.isArray(data) ? data : [data];
|
|
2135
|
-
const hasDelete = records.some((r) => r.deleteRecord?.delete === true);
|
|
2136
|
-
if (hasDelete) {
|
|
2137
|
-
hasAnyDeletions = true;
|
|
2138
|
-
break;
|
|
2139
|
-
}
|
|
2225
|
+
if (FileMayHaveDeletions(content)) {
|
|
2226
|
+
hasAnyDeletions = true;
|
|
2227
|
+
break;
|
|
2140
2228
|
}
|
|
2141
2229
|
}
|
|
2142
2230
|
catch (error) {
|
|
@@ -2180,9 +2268,7 @@ export class PushService {
|
|
|
2180
2268
|
const rawFileData = await fs.readJson(filePath);
|
|
2181
2269
|
// Handle @include directives if present
|
|
2182
2270
|
let fileData = rawFileData;
|
|
2183
|
-
|
|
2184
|
-
const hasIncludes = jsonString.includes('"@include"') || jsonString.includes('"@include.');
|
|
2185
|
-
if (hasIncludes) {
|
|
2271
|
+
if (TextHasInclude(JSON.stringify(rawFileData))) {
|
|
2186
2272
|
const jsonPreprocessor = new JsonPreprocessor();
|
|
2187
2273
|
fileData = await jsonPreprocessor.processFile(filePath);
|
|
2188
2274
|
}
|
|
@@ -2317,23 +2403,7 @@ export class PushService {
|
|
|
2317
2403
|
* Recursively check if any records in an array (including nested relatedEntities) have deletions
|
|
2318
2404
|
*/
|
|
2319
2405
|
hasAnyDeletions(records) {
|
|
2320
|
-
|
|
2321
|
-
// Check this record
|
|
2322
|
-
if (record.deleteRecord?.delete === true) {
|
|
2323
|
-
return true;
|
|
2324
|
-
}
|
|
2325
|
-
// Check nested related entities recursively
|
|
2326
|
-
if (record.relatedEntities) {
|
|
2327
|
-
for (const relatedRecords of Object.values(record.relatedEntities)) {
|
|
2328
|
-
if (Array.isArray(relatedRecords)) {
|
|
2329
|
-
if (this.hasAnyDeletions(relatedRecords)) {
|
|
2330
|
-
return true;
|
|
2331
|
-
}
|
|
2332
|
-
}
|
|
2333
|
-
}
|
|
2334
|
-
}
|
|
2335
|
-
}
|
|
2336
|
-
return false;
|
|
2406
|
+
return RecordsHaveDeletions(records);
|
|
2337
2407
|
}
|
|
2338
2408
|
/**
|
|
2339
2409
|
* Process deferred records that had lookup failures during initial processing.
|
|
@@ -2554,7 +2624,8 @@ export class PushService {
|
|
|
2554
2624
|
? resolutionCollector
|
|
2555
2625
|
: { notes: resolutionCollector.notes, fieldPrefix: resolutionCollector.fieldPrefix, EnvFields: new Map() };
|
|
2556
2626
|
try {
|
|
2557
|
-
|
|
2627
|
+
// `@parent:` in a composition child refers to its owner, as it does for a related record.
|
|
2628
|
+
const value = await this.syncEngine.processFieldValue(rawValue, entityDir, ownerRecord, null, 0, batchContext, childCollector, fieldName, recordProvider, ownerRecord);
|
|
2558
2629
|
target.Set(fieldName, value);
|
|
2559
2630
|
const variables = childCollector.EnvFields?.get(fieldName);
|
|
2560
2631
|
if (variables) {
|
|
@@ -2621,6 +2692,53 @@ export class PushService {
|
|
|
2621
2692
|
for (const warning of this.envSqlLog?.Redactor.Warnings(sqlLogPath) ?? []) {
|
|
2622
2693
|
this.addWarning(warning, callbacks);
|
|
2623
2694
|
}
|
|
2695
|
+
for (const warning of this.lookupSqlLog?.Rewriter.Warnings() ?? []) {
|
|
2696
|
+
this.addWarning(warning, callbacks);
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
/** The resolver deciding which relatedEntities groups are saved as collection items. */
|
|
2700
|
+
collectionPromotions() {
|
|
2701
|
+
if (!this.collectionPromotionResolver) {
|
|
2702
|
+
this.collectionPromotionResolver = new CollectionPromotionResolver(() => this.syncEngine.GetProvider(), this.contextUser);
|
|
2703
|
+
}
|
|
2704
|
+
return this.collectionPromotionResolver;
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* The collections to apply to a record: its own `collections`, plus, for a flattened record
|
|
2708
|
+
* (depth 0), the `relatedEntities` groups promoted to collection items. The record itself is not
|
|
2709
|
+
* changed, so its file keeps the format the author wrote.
|
|
2710
|
+
*
|
|
2711
|
+
* @returns The collections and the set of promoted items among them, or `null` when there are none.
|
|
2712
|
+
*/
|
|
2713
|
+
collectionsToApply(record, entityName, depth) {
|
|
2714
|
+
const collections = record.collections && typeof record.collections === 'object' ? { ...record.collections } : {};
|
|
2715
|
+
const promotedItems = new Set();
|
|
2716
|
+
if (depth === 0) {
|
|
2717
|
+
for (const group of this.collectionPromotions().PromotedGroups(entityName, record)) {
|
|
2718
|
+
const name = group.Promotion.CollectionName;
|
|
2719
|
+
collections[name] = [...(collections[name] ?? []), ...group.Items];
|
|
2720
|
+
group.Items.forEach((item) => promotedItems.add(item));
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
return Object.keys(collections).length > 0 ? { Collections: collections, PromotedItems: promotedItems } : null;
|
|
2724
|
+
}
|
|
2725
|
+
/** Remembers a promoted item created without a key, to receive its key once the save succeeds. */
|
|
2726
|
+
queuePromotedKey(record, item, child) {
|
|
2727
|
+
const pending = this.pendingPromotedKeys.get(record) ?? [];
|
|
2728
|
+
pending.push({ Item: item, Child: child });
|
|
2729
|
+
this.pendingPromotedKeys.set(record, pending);
|
|
2730
|
+
}
|
|
2731
|
+
/** Writes the saved keys of a record's new promoted items back into the items. */
|
|
2732
|
+
writeBackPromotedKeys(record) {
|
|
2733
|
+
const pending = this.pendingPromotedKeys.get(record);
|
|
2734
|
+
this.pendingPromotedKeys.delete(record);
|
|
2735
|
+
for (const { Item, Child } of pending ?? []) {
|
|
2736
|
+
const primaryKey = {};
|
|
2737
|
+
for (const pk of Child.EntityInfo.PrimaryKeys) {
|
|
2738
|
+
primaryKey[pk.Name] = Child.Get(pk.Name);
|
|
2739
|
+
}
|
|
2740
|
+
Item.primaryKey = primaryKey;
|
|
2741
|
+
}
|
|
2624
2742
|
}
|
|
2625
2743
|
/**
|
|
2626
2744
|
* Applies first-class composition axes onto the entity before Save (§5):
|
|
@@ -2682,8 +2800,9 @@ export class PushService {
|
|
|
2682
2800
|
}
|
|
2683
2801
|
}
|
|
2684
2802
|
// 2. Collections: owner.Lines.Create() (or load existing by PK into the collection), recurse apply on child entity
|
|
2685
|
-
|
|
2686
|
-
|
|
2803
|
+
const collectionsToApply = this.collectionsToApply(record, entityName, depth);
|
|
2804
|
+
if (collectionsToApply) {
|
|
2805
|
+
for (const [colName, colItems] of Object.entries(collectionsToApply.Collections)) {
|
|
2687
2806
|
if (!Array.isArray(colItems)) {
|
|
2688
2807
|
throw new Error(`Collection "${colName}" in ${entityName} must be an array of records. ` +
|
|
2689
2808
|
`Per-record mode wrappers (e.g. {"mode": "authoritative", "items": [...]}) are forbidden; mode is directory-level only.`);
|
|
@@ -2770,6 +2889,9 @@ export class PushService {
|
|
|
2770
2889
|
for (const [pkK, pkV] of Object.entries(childKey ?? {})) {
|
|
2771
2890
|
targetChild.Set(pkK, pkV);
|
|
2772
2891
|
}
|
|
2892
|
+
if (collectionsToApply.PromotedItems.has(itemData) && !childKey) {
|
|
2893
|
+
this.queuePromotedKey(record, itemData, targetChild);
|
|
2894
|
+
}
|
|
2773
2895
|
}
|
|
2774
2896
|
else {
|
|
2775
2897
|
matchedItemSet.add(targetChild);
|