@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.4

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 (210) hide show
  1. package/LICENSE +180 -4
  2. package/dist/agentSessions/SessionManager.d.ts +19 -5
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +27 -9
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
  7. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
  8. package/dist/auth/AuthProviderCatalogRouter.js +69 -0
  9. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
  10. package/dist/auth/AuthProviderEngine.d.ts +96 -0
  11. package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
  12. package/dist/auth/AuthProviderEngine.js +245 -0
  13. package/dist/auth/AuthProviderEngine.js.map +1 -0
  14. package/dist/auth/index.d.ts +12 -0
  15. package/dist/auth/index.d.ts.map +1 -1
  16. package/dist/auth/index.js +57 -13
  17. package/dist/auth/index.js.map +1 -1
  18. package/dist/auth/initializeProviders.d.ts +64 -1
  19. package/dist/auth/initializeProviders.d.ts.map +1 -1
  20. package/dist/auth/initializeProviders.js +117 -5
  21. package/dist/auth/initializeProviders.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +89 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +52 -35
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts.map +1 -1
  31. package/dist/context.js +21 -1
  32. package/dist/context.js.map +1 -1
  33. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  34. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
  35. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  36. package/dist/generated/generated.d.ts +619 -1597
  37. package/dist/generated/generated.d.ts.map +1 -1
  38. package/dist/generated/generated.js +3341 -17021
  39. package/dist/generated/generated.js.map +1 -1
  40. package/dist/generic/ResolverBase.d.ts +28 -1
  41. package/dist/generic/ResolverBase.d.ts.map +1 -1
  42. package/dist/generic/ResolverBase.js +57 -8
  43. package/dist/generic/ResolverBase.js.map +1 -1
  44. package/dist/generic/RunViewResolver.d.ts +4 -0
  45. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  46. package/dist/generic/RunViewResolver.js +31 -0
  47. package/dist/generic/RunViewResolver.js.map +1 -1
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +147 -14
  51. package/dist/index.js.map +1 -1
  52. package/dist/integration/CustomColumnPromoter.d.ts +79 -15
  53. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  54. package/dist/integration/CustomColumnPromoter.js +472 -97
  55. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  56. package/dist/integration/InactiveRowWarnings.d.ts +48 -0
  57. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
  58. package/dist/integration/InactiveRowWarnings.js +57 -0
  59. package/dist/integration/InactiveRowWarnings.js.map +1 -0
  60. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  61. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  62. package/dist/integration/RSUProgressBridge.js +230 -0
  63. package/dist/integration/RSUProgressBridge.js.map +1 -0
  64. package/dist/integration/SchemaRefreshLaunch.d.ts +62 -0
  65. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  66. package/dist/integration/SchemaRefreshLaunch.js +67 -0
  67. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  68. package/dist/providerConfigUnits.d.ts +20 -0
  69. package/dist/providerConfigUnits.d.ts.map +1 -0
  70. package/dist/providerConfigUnits.js +22 -0
  71. package/dist/providerConfigUnits.js.map +1 -0
  72. package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
  73. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  74. package/dist/resolvers/AdhocQueryResolver.js +20 -6
  75. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  76. package/dist/resolvers/FileResolver.d.ts +34 -4
  77. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  78. package/dist/resolvers/FileResolver.js +218 -28
  79. package/dist/resolvers/FileResolver.js.map +1 -1
  80. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
  81. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
  82. package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
  83. package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
  84. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +108 -3
  85. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  86. package/dist/resolvers/IntegrationDiscoveryResolver.js +374 -87
  87. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  88. package/dist/resolvers/QueryResolver.d.ts +5 -4
  89. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  90. package/dist/resolvers/QueryResolver.js +29 -14
  91. package/dist/resolvers/QueryResolver.js.map +1 -1
  92. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  93. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  94. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  95. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  96. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  97. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  98. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  99. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  100. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  101. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  102. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  103. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  104. package/dist/resolvers/UserResolver.d.ts +19 -17
  105. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  106. package/dist/resolvers/UserResolver.js +66 -67
  107. package/dist/resolvers/UserResolver.js.map +1 -1
  108. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  109. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  110. package/dist/resolvers/currentUserRoles.js +36 -0
  111. package/dist/resolvers/currentUserRoles.js.map +1 -0
  112. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  113. package/dist/resolvers/meetingRecordingRegistration.js +3 -6
  114. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  115. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  116. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  117. package/dist/rest/MediaAccessKeys.js +42 -1
  118. package/dist/rest/MediaAccessKeys.js.map +1 -1
  119. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  120. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  121. package/dist/rest/MediaStreamHandler.js +83 -2
  122. package/dist/rest/MediaStreamHandler.js.map +1 -1
  123. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  124. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  125. package/dist/rest/OAuthCallbackHandler.js +129 -43
  126. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  127. package/dist/rest/UploadTokenManager.d.ts +90 -0
  128. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  129. package/dist/rest/UploadTokenManager.js +156 -0
  130. package/dist/rest/UploadTokenManager.js.map +1 -0
  131. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  132. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  133. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  134. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  135. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  136. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  137. package/dist/services/StartTaskGraphDispatcher.js +21 -1
  138. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  139. package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
  140. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  141. package/dist/services/TaskGraphAgentRunner.js +23 -0
  142. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  143. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  144. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  145. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  146. package/dist/types.d.ts +14 -0
  147. package/dist/types.d.ts.map +1 -1
  148. package/dist/types.js.map +1 -1
  149. package/package.json +92 -92
  150. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  151. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  152. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  153. package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
  154. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  155. package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
  156. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  157. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  158. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  159. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  160. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  161. package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
  162. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  163. package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
  164. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  165. package/src/__tests__/SessionManager.test.ts +55 -0
  166. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  167. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  168. package/src/__tests__/config-units.test.ts +74 -0
  169. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  170. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  171. package/src/__tests__/newUsers.test.ts +726 -0
  172. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  173. package/src/agentSessions/SessionManager.ts +27 -9
  174. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  175. package/src/auth/AuthProviderEngine.ts +253 -0
  176. package/src/auth/index.ts +60 -15
  177. package/src/auth/initializeProviders.ts +128 -7
  178. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  179. package/src/config.ts +55 -36
  180. package/src/context.ts +26 -2
  181. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
  182. package/src/generated/generated.ts +2347 -10551
  183. package/src/generic/ResolverBase.ts +67 -8
  184. package/src/generic/RunViewResolver.ts +31 -0
  185. package/src/index.ts +165 -16
  186. package/src/integration/CustomColumnPromoter.ts +520 -98
  187. package/src/integration/InactiveRowWarnings.ts +85 -0
  188. package/src/integration/RSUProgressBridge.ts +248 -0
  189. package/src/integration/SchemaRefreshLaunch.ts +93 -0
  190. package/src/providerConfigUnits.ts +21 -0
  191. package/src/resolvers/AdhocQueryResolver.ts +21 -6
  192. package/src/resolvers/FileResolver.ts +200 -40
  193. package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
  194. package/src/resolvers/IntegrationDiscoveryResolver.ts +390 -71
  195. package/src/resolvers/QueryResolver.ts +25 -11
  196. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  197. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  198. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  199. package/src/resolvers/UserResolver.ts +99 -60
  200. package/src/resolvers/currentUserRoles.ts +47 -0
  201. package/src/resolvers/meetingRecordingRegistration.ts +3 -7
  202. package/src/rest/MediaAccessKeys.ts +47 -1
  203. package/src/rest/MediaStreamHandler.ts +98 -3
  204. package/src/rest/OAuthCallbackHandler.ts +149 -41
  205. package/src/rest/UploadTokenManager.ts +211 -0
  206. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  207. package/src/services/StartTaskGraphDispatcher.ts +21 -1
  208. package/src/services/TaskGraphAgentRunner.ts +23 -0
  209. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  210. package/src/types.ts +14 -0
@@ -18,8 +18,10 @@
18
18
  * goes through DDLGenerator driven by provider.PlatformKey ('sqlserver' | 'postgresql'); RSU has
19
19
  * dual SS/PG setup; IOF + field map are BaseEntity. No SS-only SQL anywhere in this file.
20
20
  *
21
- * The MJAPI restart (so the new column is exposed over GraphQL) + the JSON spread of staged
22
- * values into the new columns are M3 this stage applies the schema only (SkipRestart).
21
+ * The ADD COLUMN runs through RSU's normal pipeline migration written, committed, MJAPI
22
+ * restarted once for the whole batch because the restart is what exposes the new columns over
23
+ * GraphQL and the commit is what stops the database carrying columns the repository has no record
24
+ * of. The JSON spread of staged values into those columns happens here too.
23
25
  */
24
26
  import { LogError, LogStatus, LogStatusEx, Metadata, RunView, } from '@memberjunction/core';
25
27
  import { IntegrationEngine, CUSTOM_OVERFLOW_COLUMN, CONTENT_HASH_COLUMN, computeContentHash, buildOverflowStats, planPromotions, sanitizeColumnName, inferColumnTypeFromStats, } from '@memberjunction/integration-engine';
@@ -33,6 +35,23 @@ const OVERFLOW_SAMPLE_SIZE = 1000;
33
35
  * remainder is logged + deferred; it promotes on subsequent syncs (the keys stay captured).
34
36
  */
35
37
  const MAX_PROMOTIONS_PER_PASS = 25;
38
+ /** Page size for the full-table overflow walks (spread + stale purge). */
39
+ const OVERFLOW_PAGE_SIZE = 500;
40
+ /**
41
+ * Rows the stale purge may WRITE in one pass, the row-wise sibling of {@link MAX_PROMOTIONS_PER_PASS}.
42
+ *
43
+ * Each purged row costs one `BaseEntity.Save()` — roughly nine serialized round trips, which is the
44
+ * only write shape MJ offers today (there is no batched-update provider capability; `TransactionGroup`
45
+ * gives atomicity, not batching, since both dialect implementations loop one query per item). That
46
+ * puts the purge around 250 rows/min, so an unbounded sweep of a large table would hold the post-sync
47
+ * promotion callback open for hours.
48
+ *
49
+ * Residue is inert while it waits — the field-map-first terminate check already stops a mapped key
50
+ * being re-offered as a new column — so draining it across several syncs costs nothing but time.
51
+ * The budget bounds WRITES, not the scan: the walk keeps reading (page reads are cheap and rows
52
+ * already purged fall through in memory) so a later pass still reaches residue further down the table.
53
+ */
54
+ const MAX_PURGE_ROWS_PER_PASS = 1000;
36
55
  /** Registers the post-sync custom-column promotion hook on the IntegrationEngine singleton. */
37
56
  export function registerIntegrationCustomColumnPromoter() {
38
57
  IntegrationEngine.Instance.SetPostSyncSchemaPromotionCallback(async (ctx) => {
@@ -96,69 +115,227 @@ export class IntegrationCustomColumnPromoter {
96
115
  const integrationID = await this.resolveIntegrationID(companyIntegrationID);
97
116
  if (!integrationID)
98
117
  return NOT_PROMOTED;
99
- const columnsAdded = [];
118
+ const plans = [];
119
+ const batchInputs = [];
100
120
  for (const entityName of syncedEntityNames) {
101
121
  try {
102
- const added = await this.promoteEntity(companyIntegrationID, integrationID, entityName, customKeyStats?.[entityName]);
103
- columnsAdded.push(...added);
122
+ const planned = await this.planWorkForEntity(companyIntegrationID, entityName, customKeyStats?.[entityName]);
123
+ if (!planned)
124
+ continue; // no overflow column / no captured customs / no entity map
125
+ const { entityInfo, entityMap } = planned;
126
+ // PURGE FIRST — strip already-promoted keys from the staging JSON BEFORE any column is
127
+ // created, and before the RSU pass that would restart this process. A failed or skipped
128
+ // sync is not evidence that a column is missing, and leaving the residue in place is what
129
+ // makes an already-promoted key re-surface as a phantom "new column". Runs even when there
130
+ // is nothing new to promote, which is the only way pre-existing residue on rows the sync
131
+ // never rewrites ever gets cleaned rather than endlessly re-detected.
132
+ if (planned.stale.length > 0) {
133
+ await this.purgeStaleOverflowKeys(entityName, entityMap.ID, entityInfo, planned.stale);
134
+ }
135
+ if (planned.work.length === 0)
136
+ continue; // already converged
137
+ let work = planned.work;
138
+ // M4a: bound schema churn per pass — the remainder stays captured and promotes next sync.
139
+ if (work.length > MAX_PROMOTIONS_PER_PASS) {
140
+ LogStatus(`[CustomColumnPromoter] ${work.length} candidates on ${entityName}; promoting ${MAX_PROMOTIONS_PER_PASS} this pass, ${work.length - MAX_PROMOTIONS_PER_PASS} deferred to next sync.`);
141
+ work = work.slice(0, MAX_PROMOTIONS_PER_PASS);
142
+ }
143
+ const newColumns = work.filter(w => w.needsColumn);
144
+ const plan = { entityName, entityInfo, entityMap, work, batchIndex: -1 };
145
+ if (newColumns.length > 0) {
146
+ plan.batchIndex = batchInputs.length;
147
+ batchInputs.push(this.buildSchemaInput(entityInfo, newColumns));
148
+ }
149
+ plans.push(plan);
104
150
  }
105
151
  catch (err) {
106
- // One entity's promotion failure must not abort the others, and never the sync.
107
- LogError(`[CustomColumnPromoter] Promotion failed for entity '${entityName}': ${this.msg(err)}`);
152
+ // One entity's planning failure must not abort the others, and never the sync.
153
+ LogError(`[CustomColumnPromoter] Planning failed for entity '${entityName}': ${this.msg(err)}`);
108
154
  }
109
155
  }
110
- const promoted = columnsAdded.length > 0;
111
- if (promoted) {
112
- // Make the freshly-created EntityFields visible in-process for the next sync's mapping.
156
+ if (plans.length === 0)
157
+ return NOT_PROMOTED;
158
+ // Register the follow-up DURABLY before the restart, the way the apply-objects path does
159
+ // (IntegrationDiscoveryResolver: `rsuInput.PendingWork = [pendingPayload]`). The restart is
160
+ // what loads the regenerated entity classes, so everything downstream of the DDL — the IOF
161
+ // rows, the field maps, the overflow spread — belongs after it, not before.
162
+ //
163
+ // Carried on the first input because RSU restarts ONCE for the whole batch; the payload
164
+ // describes every entity in the pass.
165
+ if (batchInputs.length > 0) {
166
+ batchInputs[0].PendingWork = [{
167
+ WorkType: 'promote-columns',
168
+ CompanyIntegrationID: companyIntegrationID,
169
+ // Not used by the promote path (which works from PromotedColumns), but the contract
170
+ // requires them and they keep the row legible to an operator reading the table.
171
+ SourceObjectNames: plans.map(p => p.entityMap.ExternalObjectName),
172
+ SchemaName: plans[0].entityInfo.SchemaName,
173
+ CreatedAt: new Date().toISOString(),
174
+ PromotedColumns: plans.map(p => ({
175
+ EntityName: p.entityName,
176
+ EntityMapID: p.entityMap.ID,
177
+ ExternalObjectName: p.entityMap.ExternalObjectName,
178
+ IntegrationID: integrationID,
179
+ Columns: p.work.map(w => ({
180
+ SourceKey: w.sourceKey,
181
+ ColumnName: w.columnName,
182
+ SchemaFieldType: w.candidate.Inferred.SchemaFieldType,
183
+ MaxLength: w.candidate.Inferred.MaxLength,
184
+ Coverage: w.candidate.Coverage,
185
+ })),
186
+ })),
187
+ }];
188
+ batchInputs[0].ContextUser = this.user;
189
+ }
190
+ // ── PHASE 2: ONE batched RSU pass for ALL entities' ADD COLUMN migrations. ──
191
+ // RunPipelineBatch runs every migration under one lock, then ONE CodeGen + compile +
192
+ // restart + git commit — which is now genuinely one restart and one commit for the whole
193
+ // promotion, rather than one per entity. See buildSchemaInput: the inputs no longer set
194
+ // SkipRestart/SkipGitCommit, so the batch commits the migration and restarts once at the
195
+ // end, which is what exposes the new columns over GraphQL and what keeps the repository in
196
+ // step with the database.
197
+ let batchResults = [];
198
+ if (batchInputs.length > 0) {
199
+ const batch = await RuntimeSchemaManager.Instance.RunPipelineBatch(batchInputs);
200
+ batchResults = batch.Results ?? [];
201
+ // M3: make the freshly-created EntityFields + regenerated sprocs visible in-process
202
+ // ONCE for the whole batch. CRITICAL: the spread below builds sproc calls from this
203
+ // metadata; without the refresh, row.Save() uses the STALE field list that predates
204
+ // the column add and mismatches the regenerated sproc → "Error executing SQL".
113
205
  try {
114
206
  await this.provider.Refresh();
115
207
  }
116
208
  catch (err) {
117
- LogError(`[CustomColumnPromoter] provider.Refresh failed: ${this.msg(err)}`);
209
+ LogError(`[CustomColumnPromoter] post-batch Refresh failed: ${this.msg(err)}`);
210
+ }
211
+ }
212
+ // ── PHASE 3: the NO-DDL case only — IOF rows + field maps + value spread, inline. ──
213
+ //
214
+ // An entity that needed a column went through the batch, and the batch RESTARTS: `pm2
215
+ // restart` kills this process, so nothing below runs for it. Its IOF rows, field maps and
216
+ // spread are completed by the post-restart consumer from the PendingWork registered above
217
+ // (CompletePromotion) — which is also where they belong, because only after the restart are
218
+ // the regenerated entity classes loaded.
219
+ //
220
+ // What is left here is the recovery-only pass: work items that need no column, so no
221
+ // migration, so no batch entry and no restart. Those are finished inline exactly as before.
222
+ // Explicitly filtered rather than left to fall through, so this reads as the branch it is
223
+ // instead of as dead code that happens to be unreachable.
224
+ const columnsAdded = [];
225
+ for (const plan of plans) {
226
+ if (plan.batchIndex >= 0) {
227
+ const res = batchResults[plan.batchIndex];
228
+ if (!res || !res.Success) {
229
+ // DDL failed — leave everything captured; retry next promote (no partial commit
230
+ // lost). The PendingWork row stays Pending and is re-processable.
231
+ LogError(`[CustomColumnPromoter] RSU ADD COLUMN failed on ${plan.entityInfo.Name}: ${res?.ErrorMessage ?? res?.ErrorStep ?? 'unknown'} — leaving ${plan.entityName} captured for retry.`);
232
+ }
233
+ else {
234
+ // Succeeded. The restart has either already ended this process or is about to,
235
+ // and the consumer owns the follow-up — so do NOT do the metadata work here.
236
+ // Still COUNT the columns: they were promoted, and SchemaUpdatePending is
237
+ // derived from this list. The client keys its "workspace updating" state off
238
+ // that flag, so dropping these would tell it nothing happened.
239
+ const added = plan.work.filter(w => !w.recoverSpread);
240
+ LogStatus(`[CustomColumnPromoter] Promoted ${added.length} column(s) on ${plan.entityName}: ${added.map(w => w.columnName).join(', ')} — metadata + spread deferred to the post-restart consumer.`);
241
+ columnsAdded.push(...added.map(w => ({ EntityName: plan.entityName, ColumnName: w.columnName })));
242
+ }
243
+ continue;
244
+ }
245
+ try {
246
+ // Completion goes through the SAME method the post-restart consumer calls, given the
247
+ // same payload shape. There is one implementation of "finish a promotion", so the
248
+ // no-restart case can never drift from the restart case — which is the drift that
249
+ // would be impossible to notice, since only one of them runs on any given pass.
250
+ await this.CompletePromotion([{
251
+ EntityName: plan.entityName,
252
+ EntityMapID: plan.entityMap.ID,
253
+ ExternalObjectName: plan.entityMap.ExternalObjectName,
254
+ IntegrationID: integrationID,
255
+ Columns: plan.work.map(w => ({
256
+ SourceKey: w.sourceKey,
257
+ ColumnName: w.columnName,
258
+ SchemaFieldType: w.candidate.Inferred.SchemaFieldType,
259
+ MaxLength: w.candidate.Inferred.MaxLength,
260
+ Coverage: w.candidate.Coverage,
261
+ })),
262
+ }], new Map([[plan.entityName, plan.entityInfo]]));
263
+ // recoverSpread items add no column/metadata — they only finish an interrupted
264
+ // backfill, so they don't count as "columns added" (keeps SchemaUpdatePending honest).
265
+ const added = plan.work.filter(w => !w.recoverSpread);
266
+ columnsAdded.push(...added.map(w => ({ EntityName: plan.entityName, ColumnName: w.columnName })));
267
+ }
268
+ catch (err) {
269
+ // One entity's promotion failure must not abort the others, and never the sync.
270
+ LogError(`[CustomColumnPromoter] Promotion failed for entity '${plan.entityName}': ${this.msg(err)}`);
118
271
  }
119
272
  }
273
+ const promoted = columnsAdded.length > 0;
120
274
  return { Promoted: promoted, ColumnsAdded: columnsAdded, SchemaUpdatePending: promoted };
121
275
  }
122
- /** Gate → plan → promote for a single target entity. Returns the columns it added. */
123
- async promoteEntity(companyIntegrationID, integrationID, entityName, inRunStats) {
124
- const planned = await this.planWorkForEntity(companyIntegrationID, entityName, inRunStats);
125
- if (!planned || planned.work.length === 0)
126
- return []; // no overflow / no entity map / already converged
127
- const { entityInfo, entityMap } = planned;
128
- let work = planned.work;
129
- // M4a: bound schema churn per pass — the remainder stays captured and promotes next sync.
130
- if (work.length > MAX_PROMOTIONS_PER_PASS) {
131
- LogStatus(`[CustomColumnPromoter] ${work.length} candidates on ${entityName}; promoting ${MAX_PROMOTIONS_PER_PASS} this pass, ${work.length - MAX_PROMOTIONS_PER_PASS} deferred to next sync.`);
132
- work = work.slice(0, MAX_PROMOTIONS_PER_PASS);
133
- }
134
- // 4. PROMOTE: ADD COLUMN+CodeGen (only the keys that need a column) → IOF rows → field maps → spread.
135
- const newColumns = work.filter(w => w.needsColumn);
136
- if (newColumns.length > 0 && !await this.applySchemaChange(entityInfo, newColumns)) {
137
- return []; // DDL failed — leave everything captured; retry next sync (no partial commit lost)
138
- }
139
- await this.createIntegrationObjectFields(integrationID, entityMap.ExternalObjectName, work);
140
- await this.createFieldMaps(entityMap.ID, work.filter(w => w.needsFieldMap));
141
- // M3: spread staged values into the real columns + re-baseline the content hash.
142
- // CRITICAL: refresh metadata FIRST. applySchemaChange just added the columns AND had RSU
143
- // regenerate the spUpdate sproc to include them — but the running provider's in-memory entity
144
- // metadata still predates the column add (the Refresh at the top ran before it). Without this,
145
- // the spread's row.Save() builds a sproc call from the STALE field list that doesn't match the
146
- // regenerated sproc → "Error executing SQL" (the spread-save failures). Re-loading metadata
147
- // here aligns the entity's field set with the new DB sproc so the backfill saves succeed.
148
- try {
149
- await this.provider.Refresh();
150
- }
151
- catch (err) {
152
- LogError(`[CustomColumnPromoter] pre-spread Refresh failed: ${this.msg(err)}`);
276
+ /**
277
+ * Finish a promotion AFTER the restart — the IntegrationObjectField rows, the field maps and
278
+ * the overflow→column spread from the {@link RSUPendingWork} the pre-restart pass registered.
279
+ *
280
+ * Same work PHASE 3 does inline when no restart occurred; it lives here rather than in the
281
+ * consumer so promotion logic stays in one class. The difference is that here the regenerated
282
+ * entity classes ARE loaded, so the spread writes through real typed columns instead of the
283
+ * dynamic .Get/.Set the pre-restart path is forced into.
284
+ *
285
+ * Idempotent, because a pending row is marked Completed only after success and so stays
286
+ * re-processable after a crash: field maps are filtered against those already active, and the
287
+ * spread only fills a destination that is still empty.
288
+ */
289
+ async CompletePromotion(promoted, knownEntities) {
290
+ const columnsAdded = [];
291
+ for (const entry of promoted) {
292
+ try {
293
+ // Provider first — post-restart it holds the REGENERATED class, which is the whole
294
+ // reason completion was deferred. knownEntities is the inline caller handing back the
295
+ // EntityInfo it already resolved, for the no-DDL pass where nothing was regenerated.
296
+ const entityInfo = this.provider.EntityByName(entry.EntityName) ?? knownEntities?.get(entry.EntityName);
297
+ if (!entityInfo) {
298
+ LogError(`[CustomColumnPromoter] Post-restart: entity '${entry.EntityName}' not found; skipping.`);
299
+ continue;
300
+ }
301
+ // Destination names are CARRIED, never recomputed: uniqueColumnName may have
302
+ // suffixed one to dodge a collision, and re-deriving it here could pick a different
303
+ // name than the column the migration actually created.
304
+ const existingMaps = await this.activeFieldMaps(entry.EntityMapID);
305
+ const named = entry.Columns.map(c => ({
306
+ candidate: {
307
+ Key: c.SourceKey,
308
+ Coverage: c.Coverage,
309
+ // SqlServerType/PostgresType drive DDL only, which ran before the restart.
310
+ // The phase-3 work below reads SchemaFieldType and MaxLength.
311
+ Inferred: {
312
+ SchemaFieldType: c.SchemaFieldType,
313
+ MaxLength: c.MaxLength,
314
+ SqlServerType: '',
315
+ PostgresType: '',
316
+ },
317
+ },
318
+ sourceKey: c.SourceKey,
319
+ columnName: c.ColumnName,
320
+ needsColumn: false,
321
+ needsFieldMap: !existingMaps.has(c.SourceKey.toLowerCase()),
322
+ }));
323
+ await this.createIntegrationObjectFields(entry.IntegrationID, entry.ExternalObjectName, named);
324
+ await this.createFieldMaps(entry.EntityMapID, named.filter(n => n.needsFieldMap));
325
+ await this.spreadAndRebaseline(entry.EntityName, entry.EntityMapID, entityInfo, named);
326
+ LogStatus(`[CustomColumnPromoter] Post-restart: completed ${named.length} promoted column(s) on ${entry.EntityName}: ${named.map(n => n.columnName).join(', ')}`);
327
+ columnsAdded.push(...named.map(n => ({ EntityName: entry.EntityName, ColumnName: n.columnName })));
328
+ }
329
+ catch (err) {
330
+ // One entity must not abort the others; the pending row stays open for retry.
331
+ LogError(`[CustomColumnPromoter] Post-restart completion failed for '${entry.EntityName}': ${this.msg(err)}`);
332
+ }
153
333
  }
154
- const refreshedEntityInfo = this.provider.EntityByName(entityName) ?? entityInfo;
155
- await this.spreadAndRebaseline(entityName, entityMap.ID, refreshedEntityInfo, work);
156
- LogStatus(`[CustomColumnPromoter] Promoted/recovered ${work.length} custom column(s) on ${entityName}: ${work.map(w => w.columnName).join(', ')}`);
157
- return work.map(w => ({ EntityName: entityName, ColumnName: w.columnName }));
334
+ return columnsAdded;
158
335
  }
159
336
  /**
160
337
  * Dry-run of GATE → scan → PLAN → resolve-work for ONE entity, WITHOUT applying any schema change.
161
- * Shared by {@link promoteEntity} (which then PROMOTES the work) and {@link ListCandidates} (which only
338
+ * Shared by {@link PromoteForSync} (phase 1, which then batch-PROMOTES the work) and {@link ListCandidates} (which only
162
339
  * reports it). Returns null when the entity has no overflow column / no captured customs / no entity map.
163
340
  * Because the work list is computed live (overflow keys minus already-column-and-mapped), re-running is
164
341
  * inherently deduped — a concurrent discovery that already promoted a key yields no work item for it.
@@ -176,6 +353,15 @@ export class IntegrationCustomColumnPromoter {
176
353
  // out-of-band capture; the hash basis excludes overflow so skips never write it),
177
354
  // 3. candidates persisted from prior runs (survive restarts for on-demand listing).
178
355
  const overflowJson = await this.scanOverflow(entityName);
356
+ // Every key literally present in the staging JSON right now. Kept separate from the candidate
357
+ // set below, which drops low-coverage keys — a stale key must be purged regardless of coverage.
358
+ const liveOverflowKeys = new Set();
359
+ for (const raw of overflowJson) {
360
+ const parsed = this.parseOverflow(raw);
361
+ if (parsed)
362
+ for (const k of Object.keys(parsed))
363
+ liveOverflowKeys.add(k);
364
+ }
179
365
  // U3 note (rkihm-BC review, #3061): this in-repo promotion path passes no `LockUntilFullSync`, so it
180
366
  // does NOT yet enforce "hold promotion until a full sync since the last schema change." The lever
181
367
  // exists on `PromotionPlanOptions`, but pulling it here requires this caller to know whether a full
@@ -192,7 +378,10 @@ export class IntegrationCustomColumnPromoter {
192
378
  if (!byKey.has(c.Key))
193
379
  byKey.set(c.Key, c);
194
380
  const passing = [...byKey.values()];
195
- if (passing.length === 0)
381
+ // Nothing captured AND nothing staged — genuinely nothing to look at. A key can be staged
382
+ // without being a candidate (coverage filtered it out), and that key still needs purging, so
383
+ // this deliberately does NOT return early on `passing.length === 0` alone.
384
+ if (passing.length === 0 && liveOverflowKeys.size === 0)
196
385
  return null;
197
386
  const entityMap = await this.findEntityMap(companyIntegrationID, entityName);
198
387
  if (!entityMap) {
@@ -200,9 +389,19 @@ export class IntegrationCustomColumnPromoter {
200
389
  return null;
201
390
  }
202
391
  // Skip fully-terminated keys (column + field map both exist); keep promote (needs column) / recover.
203
- const fieldMapSources = await this.activeFieldMapSources(entityMap.ID);
204
- const work = this.resolveWorkItems(passing, entityInfo, fieldMapSources);
205
- return { entityInfo, entityMap, work };
392
+ const fieldMaps = await this.activeFieldMaps(entityMap.ID);
393
+ const work = this.resolveWorkItems(passing, entityInfo, fieldMaps);
394
+ // Keys that are ALREADY promoted (active field map) yet whose value is still sitting in the
395
+ // staging JSON. These are not candidates — they are residue, and they cannot clear themselves:
396
+ // the next sync only rewrites a row whose content hash changed, and the hash basis excludes the
397
+ // overflow column, so a row untouched since before the promotion keeps the key indefinitely.
398
+ const stale = [];
399
+ for (const key of liveOverflowKeys) {
400
+ const columnName = fieldMaps.get(key.toLowerCase());
401
+ if (columnName)
402
+ stale.push({ sourceKey: key, columnName });
403
+ }
404
+ return { entityInfo, entityMap, work, stale };
206
405
  }
207
406
  /**
208
407
  * Lists the custom-column CANDIDATES for one entity — the "new columns found" awaiting promotion,
@@ -213,7 +412,9 @@ export class IntegrationCustomColumnPromoter {
213
412
  const planned = await this.planWorkForEntity(companyIntegrationID, entityName);
214
413
  if (!planned)
215
414
  return [];
216
- return planned.work.map(w => ({
415
+ // recoverSpread items are not "new columns found" — they are an internal backfill-recovery
416
+ // signal (column + field map already exist), so they must not surface as UI candidates.
417
+ return planned.work.filter(w => !w.recoverSpread).map(w => ({
217
418
  EntityName: entityName,
218
419
  SourceKey: w.sourceKey,
219
420
  ColumnName: w.columnName,
@@ -326,16 +527,30 @@ export class IntegrationCustomColumnPromoter {
326
527
  * map is missing becomes a RECOVERY item (covers the crash window between ADD COLUMN and the
327
528
  * field-map write) — needsColumn=false, needsFieldMap=true.
328
529
  */
329
- resolveWorkItems(passing, entityInfo, fieldMapSources) {
530
+ resolveWorkItems(passing, entityInfo, fieldMaps) {
330
531
  const existingByLower = new Map(entityInfo.Fields.map(f => [f.Name.toLowerCase(), f.Name]));
331
532
  const taken = new Set(entityInfo.Fields.map(f => f.Name.toLowerCase()));
332
533
  const items = [];
333
534
  for (const candidate of passing) {
334
- const existingCol = existingByLower.get(sanitizeColumnName(candidate.Key).toLowerCase());
535
+ // An ACTIVE field map is the authoritative proof that this key was already promoted, and it
536
+ // names the column that was created. Trust it ahead of a re-sanitized guess against the
537
+ // in-memory field list: that list can predate the ADD COLUMN in THIS process (the promoter
538
+ // refreshes its own provider, not every other one), and the real column may carry a
539
+ // collision suffix the guess cannot reproduce. Both misses used to read as "no column yet",
540
+ // re-offering an already-promoted key to the operator as a brand-new column — and, on
541
+ // promotion, minting a duplicate `_2` column beside the working one.
542
+ const mappedDest = fieldMaps.get(candidate.Key.toLowerCase());
543
+ const hasFieldMap = mappedDest !== undefined;
544
+ const existingCol = (mappedDest ? existingByLower.get(mappedDest.toLowerCase()) ?? mappedDest : undefined) ??
545
+ existingByLower.get(sanitizeColumnName(candidate.Key).toLowerCase());
335
546
  const hasColumn = !!existingCol;
336
- const hasFieldMap = fieldMapSources.has(candidate.Key.toLowerCase());
337
- if (hasColumn && hasFieldMap)
338
- continue; // terminated nothing to do
547
+ if (hasColumn && hasFieldMap) {
548
+ // Schema + mapping are terminated — but see WorkItem.recoverSpread: an interrupted
549
+ // spread leaves rows carrying the value only in overflow, and this was the one exit
550
+ // that made that state permanent. Keep it as a spread-recovery item instead.
551
+ items.push({ candidate, sourceKey: candidate.Key, columnName: existingCol, needsColumn: false, needsFieldMap: false, recoverSpread: true });
552
+ continue;
553
+ }
339
554
  const columnName = existingCol ?? this.uniqueColumnName(sanitizeColumnName(candidate.Key), taken);
340
555
  if (!hasColumn)
341
556
  taken.add(columnName.toLowerCase());
@@ -343,16 +558,33 @@ export class IntegrationCustomColumnPromoter {
343
558
  }
344
559
  return items;
345
560
  }
346
- /** Active field-map SOURCE field names for an entity map (lowercased) — for the terminate/recovery check. */
347
- async activeFieldMapSources(entityMapID) {
561
+ /**
562
+ * Active field maps for an entity map, as `lowercased SourceFieldName -> DestinationFieldName`.
563
+ *
564
+ * The destination name is what makes this authoritative: it records the column promotion ACTUALLY
565
+ * created, which may carry a collision suffix (`_2`) that re-sanitizing the source key can never
566
+ * reproduce. One query serves the terminate/recovery check, the stale-key detection and the hash
567
+ * re-baseline, where there used to be two identical queries and a lossier projection.
568
+ */
569
+ async activeFieldMaps(entityMapID) {
348
570
  const rv = new RunView();
349
571
  const res = await rv.RunView({
350
572
  EntityName: 'MJ: Company Integration Field Maps',
351
573
  ExtraFilter: `EntityMapID='${entityMapID}' AND Status='Active'`,
352
- Fields: ['SourceFieldName'],
574
+ Fields: ['SourceFieldName', 'DestinationFieldName'],
353
575
  ResultType: 'simple',
354
576
  }, this.user);
355
- return new Set(res.Success ? (res.Results ?? []).map(r => (r.SourceFieldName ?? '').toLowerCase()) : []);
577
+ const maps = new Map();
578
+ if (res.Success) {
579
+ for (const r of res.Results ?? []) {
580
+ const source = (r.SourceFieldName ?? '').toLowerCase();
581
+ // '' destination keeps the key MAPPED for the terminate check while signalling that no
582
+ // column name is recoverable from it — a `has()` miss and a `get()` of '' are different answers.
583
+ if (source)
584
+ maps.set(source, r.DestinationFieldName ?? '');
585
+ }
586
+ }
587
+ return maps;
356
588
  }
357
589
  /** Suffixes _2, _3, … until the sanitized name does not collide with an existing/assigned one. */
358
590
  uniqueColumnName(base, taken) {
@@ -365,25 +597,52 @@ export class IntegrationCustomColumnPromoter {
365
597
  }
366
598
  return `${base}_${Date.now() % 100000}`; // pathological fallback (never expected)
367
599
  }
368
- /** Generates ADD COLUMN DDL for the new columns and runs it through RSU (CodeGen reflects them). */
369
- async applySchemaChange(entityInfo, named) {
600
+ /**
601
+ * Builds the RSUPipelineInput (ADD COLUMN DDL) for one entity's new columns — pure, runs NO
602
+ * pipeline. PromoteForSync collects these across all entities and runs ONE RunPipelineBatch so
603
+ * the whole promote is a single CodeGen/compile/restart pass.
604
+ */
605
+ buildSchemaInput(entityInfo, named) {
370
606
  const platform = this.dbProvider.PlatformKey;
371
607
  const ddl = new DDLGenerator();
372
608
  const statements = named.map(n => ddl.GenerateAlterTableAddColumn(entityInfo.SchemaName, entityInfo.BaseTable, this.toTargetColumn(n, platform), platform));
373
- const input = {
609
+ return {
374
610
  MigrationSQL: statements.join('\n'),
375
611
  Description: `Promote ${named.length} custom column(s) on ${entityInfo.Name}`,
376
612
  AffectedTables: [`${entityInfo.SchemaName}.${entityInfo.BaseTable}`],
377
- // M3 owns the restart-signal + restart; this stage applies schema only. Runtime
378
- // promotion creates no git commit (the dev RSU flow does; a per-sync commit is noise).
379
- SkipRestart: true,
380
- SkipGitCommit: true,
613
+ // Neither SkipRestart nor SkipGitCommit is set, so RSU runs its normal pipeline. Both
614
+ // are optional and RSU gates on `!inputs.every(i => i.SkipGitCommit)`, so omitting them
615
+ // IS the default — no caller or platform change is needed to get commit + restart.
616
+ //
617
+ // `pm2 restart` kills this process, so PHASE 3 below does NOT run when the restart
618
+ // happens. That is why the follow-up is registered as PendingWork above: the
619
+ // post-restart consumer completes it, with the regenerated entity classes actually
620
+ // loaded. PHASE 3 remains as the fallback for the case where no DDL was needed (no
621
+ // batch, so no restart) — mirroring the apply path, which likewise finishes inline only
622
+ // when the restart did not occur.
623
+ //
624
+ // Both flags were previously hardcoded true here, the only place in the repo either was
625
+ // forced rather than passed in. Every integration entry point takes them as arguments
626
+ // defaulting to false, so add/remove tables, refresh schema and first-time setup all
627
+ // commit and restart; promotion was the outlier. Without the restart the columns never
628
+ // reached GraphQL — "already usable (metadata refreshed)" conflated metadata with CODE,
629
+ // since Refresh() reloads EntityField rows but does not load regenerated classes into a
630
+ // running process. Without the commit the database carried columns git had no record of.
631
+ // SkipGitCommit, by contrast, was wrong and is now dropped, so the migration and the
632
+ // regenerated code reach the repository. It is the only place in the repo either flag
633
+ // was forced rather than passed in; every integration entry point takes them as
634
+ // arguments defaulting to false, so add/remove tables, refresh schema and first-time
635
+ // setup all commit already. Without the commit the database carries columns git has no
636
+ // record of — observed live, where a workspace's promoted columns were present only
637
+ // because a LATER schema refresh happened to re-emit them as ADD COLUMN IF NOT EXISTS.
638
+ // The commit does not touch the process, so it is safe where the restart is not.
639
+ //
640
+ // The restart itself remains genuinely unsolved: it has to happen AFTER phase 3, and
641
+ // nothing performs it — `sync.schema_update` carries restartRequiredForGraphQL: true
642
+ // and has no subscriber, while the client arms its RSU poll and waits for a restart
643
+ // that never comes. Until that is closed, the columns do not reach GraphQL. Deliberately
644
+ // NOT fixed by restarting here, which would break promotion outright.
381
645
  };
382
- const result = await RuntimeSchemaManager.Instance.RunPipeline(input);
383
- if (!result.Success) {
384
- LogError(`[CustomColumnPromoter] RSU ADD COLUMN failed on ${entityInfo.Name}: ${result.ErrorMessage ?? result.ErrorStep ?? 'unknown'}`);
385
- }
386
- return result.Success;
387
646
  }
388
647
  /** Builds a per-platform TargetColumnConfig from a planned candidate. */
389
648
  toTargetColumn(n, platform) {
@@ -476,55 +735,176 @@ export class IntegrationCustomColumnPromoter {
476
735
  /**
477
736
  * Spreads the staged overflow JSON values into the freshly-created real columns, then
478
737
  * re-baselines the content hash (gaps.md §2 step 3). JS per-row pass — dialect-agnostic (no
479
- * cast SQL, which is where PG bugs hide); BaseEntity handles the dialect on write. The overflow
480
- * column is intentionally NOT cleared: once the field map exists the key is no longer "unmapped",
481
- * so the next sync stops re-capturing it and planPromotions skips the now-existing column — the
482
- * stale value self-heals. Bounded to rows that carry overflow, paged, once on the discovery sync.
738
+ * cast SQL, which is where PG bugs hide); BaseEntity handles the dialect on write. Each promoted
739
+ * key is also STRIPPED from the staging JSON as it is spread: it is no longer unmapped, and
740
+ * leaving it behind is what later re-surfaces it as a phantom new column (see
741
+ * {@link purgeStaleOverflowKeys}). Bounded to rows that carry overflow, paged.
483
742
  */
484
743
  async spreadAndRebaseline(entityName, entityMapID, entityInfo, named) {
485
744
  const hasHashCol = entityInfo.Fields.some(f => f.Name === CONTENT_HASH_COLUMN);
486
745
  const mappedDestFields = hasHashCol ? await this.activeDestinationFields(entityMapID) : [];
487
- let startRow = 0;
488
- const pageSize = 500;
746
+ await this.forEachOverflowRow(entityName, entityInfo, row => this.spreadOneRow(row, named, hasHashCol, mappedDestFields));
747
+ }
748
+ /**
749
+ * Removes already-promoted keys from the staging JSON across the WHOLE table, spreading each
750
+ * value into its real column first if that column is still empty (a row promoted before the
751
+ * spread ever reached it must not lose the value on the way out).
752
+ *
753
+ * This exists because the residue cannot self-heal. The sync rewrites a row only when its content
754
+ * hash changes, and the hash basis deliberately excludes the overflow column — so a row that has
755
+ * not changed since before the promotion is never rewritten, keeps the promoted key forever, and
756
+ * keeps that key showing up in the candidate listing as a new column to add.
757
+ */
758
+ async purgeStaleOverflowKeys(entityName, entityMapID, entityInfo, stale) {
759
+ const columns = new Set(entityInfo.Fields.map(f => f.Name.toLowerCase()));
760
+ const purgeable = stale.filter(k => columns.has(k.columnName.toLowerCase()));
761
+ if (purgeable.length === 0) {
762
+ // Mapped but no such column in this process's metadata: the map may be newer than the
763
+ // metadata, so DON'T strip the staged value — that would be the only copy of it.
764
+ LogStatus(`[CustomColumnPromoter] ${stale.length} mapped key(s) on ${entityName} have no column in current metadata; purge skipped this pass.`);
765
+ return;
766
+ }
767
+ // Same re-baseline as the spread: the promoted columns are already in the field maps, so they
768
+ // are already part of what the next sync hashes. Backfilling one without re-baselining would
769
+ // leave every purged row hash-mismatched and force a needless rewrite on the next sync.
770
+ const hasHashCol = entityInfo.Fields.some(f => f.Name === CONTENT_HASH_COLUMN);
771
+ const mappedDestFields = hasHashCol ? await this.activeDestinationFields(entityMapID) : [];
772
+ let written = 0;
773
+ let removedFromSet = 0;
774
+ await this.forEachOverflowRow(entityName, entityInfo, async (row) => {
775
+ const result = await this.purgeOneRow(row, purgeable, hasHashCol, mappedDestFields);
776
+ if (result.wrote)
777
+ written++;
778
+ if (result.leftSet)
779
+ removedFromSet++;
780
+ return result.leftSet;
781
+ }, () => written >= MAX_PURGE_ROWS_PER_PASS);
782
+ const keys = purgeable.map(k => k.sourceKey).join(', ');
783
+ if (written >= MAX_PURGE_ROWS_PER_PASS) {
784
+ LogStatus(`[CustomColumnPromoter] Purged ${keys} from ${written} row(s) on ${entityName} — per-pass budget reached; any remaining residue is purged on the next sync.`);
785
+ }
786
+ else {
787
+ LogStatus(`[CustomColumnPromoter] Purged ${keys} from the staging JSON of ${written} row(s) on ${entityName} (already promoted).`);
788
+ }
789
+ }
790
+ /**
791
+ * Walks every row of `entityName` whose overflow JSON is non-null, in pages, applying `visit`.
792
+ *
793
+ * `visit` reports whether the row LEFT the filtered set (its JSON went null). Those removals shift
794
+ * every later row toward the front, so the offset advances by rows-seen-minus-rows-removed rather
795
+ * than by page size — otherwise the walk skips exactly as many rows as it cleans. Ordering is
796
+ * pinned to the primary key so the offsets refer to a stable sequence across the paged queries.
797
+ *
798
+ * `stop` is checked before each row and ends the walk early — a write budget, not a scan limit,
799
+ * so a capped caller still reads past rows it has nothing to do on. See {@link MAX_PURGE_ROWS_PER_PASS}.
800
+ */
801
+ async forEachOverflowRow(entityName, entityInfo, visit, stop) {
802
+ const orderBy = entityInfo.PrimaryKeys.map(pk => pk.Name).join(', ');
803
+ let seen = 0;
804
+ let removed = 0;
489
805
  for (;;) {
490
806
  const rv = new RunView();
491
807
  const res = await rv.RunView({
492
808
  EntityName: entityName,
493
809
  ExtraFilter: `${CUSTOM_OVERFLOW_COLUMN} IS NOT NULL`,
810
+ OrderBy: orderBy.length > 0 ? orderBy : undefined,
494
811
  ResultType: 'entity_object',
495
- MaxRows: pageSize,
496
- StartRow: startRow,
812
+ MaxRows: OVERFLOW_PAGE_SIZE,
813
+ StartRow: seen - removed,
497
814
  }, this.user);
498
815
  if (!res.Success) {
499
- LogError(`[CustomColumnPromoter] Spread scan failed for ${entityName}: ${res.ErrorMessage}`);
816
+ LogError(`[CustomColumnPromoter] Overflow row scan failed for ${entityName}: ${res.ErrorMessage}`);
500
817
  return;
501
818
  }
502
819
  const rows = res.Results ?? [];
503
820
  for (const row of rows) {
504
- await this.spreadOneRow(row, named, hasHashCol, mappedDestFields);
821
+ if (stop?.())
822
+ return;
823
+ if (await visit(row))
824
+ removed++;
825
+ seen++;
505
826
  }
506
- if (rows.length < pageSize)
827
+ if (rows.length < OVERFLOW_PAGE_SIZE)
507
828
  break;
508
- startRow += pageSize;
509
829
  }
510
830
  }
511
- /** Applies the staged values + re-baselined hash to a single row entity and saves it. */
831
+ /**
832
+ * Strips the given already-promoted keys from one row's staging JSON, backfilling any real column
833
+ * that is still empty.
834
+ *
835
+ * Reports both facts the caller needs and they are NOT the same: `wrote` is what the per-pass
836
+ * budget spends (one Save), `leftSet` is whether the row dropped out of the `IS NOT NULL` filter
837
+ * and so shifts the paged offsets. A row that keeps other unmapped keys is written but stays.
838
+ */
839
+ async purgeOneRow(row, stale, hasHashCol, mappedDestFields) {
840
+ const overflow = this.parseOverflow(row.Get(CUSTOM_OVERFLOW_COLUMN));
841
+ if (!overflow)
842
+ return { wrote: false, leftSet: false };
843
+ let changed = false;
844
+ for (const k of stale) {
845
+ if (!Object.prototype.hasOwnProperty.call(overflow, k.sourceKey))
846
+ continue;
847
+ const current = row.Get(k.columnName);
848
+ if (current === null || current === undefined)
849
+ row.Set(k.columnName, overflow[k.sourceKey]);
850
+ delete overflow[k.sourceKey];
851
+ changed = true;
852
+ }
853
+ // Nothing of ours in this row — a pure read, so it costs no budget. This is the case that makes
854
+ // a capped pass still able to reach residue further down: rows an earlier pass already cleaned
855
+ // fall through here instead of being re-written.
856
+ if (!changed)
857
+ return { wrote: false, leftSet: false };
858
+ const remaining = Object.keys(overflow).length > 0 ? JSON.stringify(overflow) : null;
859
+ row.Set(CUSTOM_OVERFLOW_COLUMN, remaining);
860
+ if (hasHashCol) {
861
+ const mapped = {};
862
+ for (const dest of mappedDestFields)
863
+ mapped[dest] = row.Get(dest);
864
+ row.Set(CONTENT_HASH_COLUMN, computeContentHash(mapped));
865
+ }
866
+ if (!await row.Save()) {
867
+ LogError(`[CustomColumnPromoter] Overflow purge save failed: ${row.LatestResult?.CompleteMessage ?? 'unknown'}`);
868
+ return { wrote: false, leftSet: false };
869
+ }
870
+ return { wrote: true, leftSet: remaining === null };
871
+ }
872
+ /**
873
+ * Applies the staged values + re-baselined hash to a single row entity and saves it. Returns true
874
+ * when the row's overflow JSON went null (it left the filtered set — see {@link forEachOverflowRow}).
875
+ */
512
876
  async spreadOneRow(row, named, hasHashCol, mappedDestFields) {
513
877
  // Dynamic .Get/.Set is REQUIRED here: these columns were created at runtime and have no
514
878
  // generated typed property in this still-running process (full typed access arrives on the
515
879
  // post-promotion restart). This is the sanctioned exception to the no-.Get/.Set rule.
516
880
  const overflow = this.parseOverflow(row.Get(CUSTOM_OVERFLOW_COLUMN));
517
881
  if (!overflow)
518
- return;
882
+ return false;
519
883
  let changed = false;
520
884
  for (const n of named) {
521
885
  if (Object.prototype.hasOwnProperty.call(overflow, n.sourceKey)) {
522
- row.Set(n.columnName, overflow[n.sourceKey]);
886
+ // Idempotent spread: only WRITE when the destination is still unset. A freshly-added
887
+ // column is null on first spread (so it fills); an already-backfilled column is left
888
+ // alone, so a re-run after an interrupted spread finishes the gap without overwriting
889
+ // a settled value.
890
+ const current = row.Get(n.columnName);
891
+ if (current === null || current === undefined) {
892
+ row.Set(n.columnName, overflow[n.sourceKey]);
893
+ }
894
+ // The STRIP is unconditional, and deliberately outside the guard above: the key now has
895
+ // a real column and an active field map, so it is no longer unmapped whether or not this
896
+ // pass was the one that filled it. Leaving it behind relies on a later sync to evict it,
897
+ // which never happens for a row whose content hash does not change (the hash basis
898
+ // excludes this column). Stripping is what makes the recovery pass converge — once the
899
+ // key is gone from the JSON the row drops out of the scan entirely.
900
+ delete overflow[n.sourceKey];
523
901
  changed = true;
524
902
  }
525
903
  }
526
904
  if (!changed)
527
- return;
905
+ return false;
906
+ const remaining = Object.keys(overflow).length > 0 ? JSON.stringify(overflow) : null;
907
+ row.Set(CUSTOM_OVERFLOW_COLUMN, remaining);
528
908
  if (hasHashCol) {
529
909
  // Re-baseline to the next-sync value: hash over all active mapped destination columns
530
910
  // (now incl. the new ones) as they sit on the row — matches what the next sync computes.
@@ -535,18 +915,13 @@ export class IntegrationCustomColumnPromoter {
535
915
  }
536
916
  if (!await row.Save()) {
537
917
  LogError(`[CustomColumnPromoter] Spread save failed: ${row.LatestResult?.CompleteMessage ?? 'unknown'}`);
918
+ return false;
538
919
  }
920
+ return remaining === null;
539
921
  }
540
922
  /** Active field-map destination column names for an entity map (for hash re-baseline). */
541
923
  async activeDestinationFields(entityMapID) {
542
- const rv = new RunView();
543
- const res = await rv.RunView({
544
- EntityName: 'MJ: Company Integration Field Maps',
545
- ExtraFilter: `EntityMapID='${entityMapID}' AND Status='Active'`,
546
- Fields: ['DestinationFieldName'],
547
- ResultType: 'simple',
548
- }, this.user);
549
- return res.Success ? (res.Results ?? []).map(r => r.DestinationFieldName).filter(Boolean) : [];
924
+ return [...(await this.activeFieldMaps(entityMapID)).values()].filter(Boolean);
550
925
  }
551
926
  parseOverflow(raw) {
552
927
  if (typeof raw !== 'string' || raw.length === 0)