@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.
Files changed (75) hide show
  1. package/README.md +151 -2
  2. package/dist/config.d.ts +37 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/RecordProcessor.d.ts +16 -0
  9. package/dist/lib/RecordProcessor.d.ts.map +1 -1
  10. package/dist/lib/RecordProcessor.js +51 -4
  11. package/dist/lib/RecordProcessor.js.map +1 -1
  12. package/dist/lib/collection-promotion.d.ts +89 -0
  13. package/dist/lib/collection-promotion.d.ts.map +1 -0
  14. package/dist/lib/collection-promotion.js +154 -0
  15. package/dist/lib/collection-promotion.js.map +1 -0
  16. package/dist/lib/deletion-auditor.d.ts.map +1 -1
  17. package/dist/lib/deletion-auditor.js +1 -1
  18. package/dist/lib/deletion-auditor.js.map +1 -1
  19. package/dist/lib/deletion-prescan.d.ts +26 -0
  20. package/dist/lib/deletion-prescan.d.ts.map +1 -0
  21. package/dist/lib/deletion-prescan.js +56 -0
  22. package/dist/lib/deletion-prescan.js.map +1 -0
  23. package/dist/lib/env-sql-redactor.d.ts +1 -1
  24. package/dist/lib/env-sql-redactor.d.ts.map +1 -1
  25. package/dist/lib/env-sql-redactor.js +7 -72
  26. package/dist/lib/env-sql-redactor.js.map +1 -1
  27. package/dist/lib/lookup-sql.d.ts +109 -0
  28. package/dist/lib/lookup-sql.d.ts.map +1 -0
  29. package/dist/lib/lookup-sql.js +243 -0
  30. package/dist/lib/lookup-sql.js.map +1 -0
  31. package/dist/lib/natural-key-preflight.d.ts +86 -0
  32. package/dist/lib/natural-key-preflight.d.ts.map +1 -0
  33. package/dist/lib/natural-key-preflight.js +174 -0
  34. package/dist/lib/natural-key-preflight.js.map +1 -0
  35. package/dist/lib/record-dependency-analyzer.d.ts +14 -3
  36. package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
  37. package/dist/lib/record-dependency-analyzer.js +59 -72
  38. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  39. package/dist/lib/schema-placeholders.d.ts +29 -0
  40. package/dist/lib/schema-placeholders.d.ts.map +1 -0
  41. package/dist/lib/schema-placeholders.js +72 -0
  42. package/dist/lib/schema-placeholders.js.map +1 -0
  43. package/dist/lib/sql-log-filters.d.ts +31 -0
  44. package/dist/lib/sql-log-filters.d.ts.map +1 -0
  45. package/dist/lib/sql-log-filters.js +41 -0
  46. package/dist/lib/sql-log-filters.js.map +1 -0
  47. package/dist/lib/sql-logger.d.ts +9 -1
  48. package/dist/lib/sql-logger.d.ts.map +1 -1
  49. package/dist/lib/sql-logger.js +12 -7
  50. package/dist/lib/sql-logger.js.map +1 -1
  51. package/dist/lib/sql-set-line.d.ts +26 -0
  52. package/dist/lib/sql-set-line.d.ts.map +1 -0
  53. package/dist/lib/sql-set-line.js +84 -0
  54. package/dist/lib/sql-set-line.js.map +1 -0
  55. package/dist/lib/sync-engine.d.ts +14 -2
  56. package/dist/lib/sync-engine.d.ts.map +1 -1
  57. package/dist/lib/sync-engine.js +68 -9
  58. package/dist/lib/sync-engine.js.map +1 -1
  59. package/dist/services/PullService.d.ts +2 -0
  60. package/dist/services/PullService.d.ts.map +1 -1
  61. package/dist/services/PullService.js +20 -2
  62. package/dist/services/PullService.js.map +1 -1
  63. package/dist/services/PushService.d.ts +42 -0
  64. package/dist/services/PushService.d.ts.map +1 -1
  65. package/dist/services/PushService.js +180 -58
  66. package/dist/services/PushService.js.map +1 -1
  67. package/dist/services/ValidationService.d.ts +31 -0
  68. package/dist/services/ValidationService.d.ts.map +1 -1
  69. package/dist/services/ValidationService.js +135 -9
  70. package/dist/services/ValidationService.js.map +1 -1
  71. package/dist/services/WatchService.d.ts +2 -0
  72. package/dist/services/WatchService.d.ts.map +1 -1
  73. package/dist/services/WatchService.js +19 -2
  74. package/dist/services/WatchService.js.map +1 -1
  75. 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
- TransformStatement: this.envSqlLog.Redactor.Transform,
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: this.syncConfig?.sqlLogging?.filterPatterns,
382
- filterType: this.syncConfig?.sqlLogging?.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 that changes to included files are properly detected.
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.calculateChecksum(fileData);
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 analysisResult = await analyzer.analyzeFileRecords(records, entityConfig.entity);
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.calculateChecksum(fileData);
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
- if (allowDefer && options.incremental && record.sync?.checksum && record.fields && record.primaryKey && !record.deleteRecord) {
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
- const existingEntity = await this.syncEngine.loadEntity(entityName, resolvedKey);
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
- const pkDisplay = Object.entries(record.primaryKey)
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 directives without full processing
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
- // Fast string check for delete directives
2131
- if (content.includes('"delete"') && content.includes('true')) {
2132
- // More precise check - parse JSON to confirm
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
- const jsonString = JSON.stringify(rawFileData);
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
- for (const record of records) {
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
- const value = await this.syncEngine.processFieldValue(rawValue, entityDir, null, null, 0, batchContext, childCollector, fieldName, recordProvider, ownerRecord);
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
- if (record.collections && typeof record.collections === 'object') {
2686
- for (const [colName, colItems] of Object.entries(record.collections)) {
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);