@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 {
25
27
  LogError,
@@ -62,7 +64,7 @@ interface PersistedCustomKeyCandidate {
62
64
  }
63
65
  import type { BaseEntity } from '@memberjunction/core';
64
66
  import { DDLGenerator, type TargetColumnConfig, type DatabasePlatform } from '@memberjunction/integration-schema-builder';
65
- import { RuntimeSchemaManager, type RSUPipelineInput } from '@memberjunction/schema-engine';
67
+ import { RuntimeSchemaManager, type RSUPipelineInput, type RSUPipelineResult, type RSUPendingWork } from '@memberjunction/schema-engine';
66
68
  import {
67
69
  MJCompanyIntegrationEntity,
68
70
  MJCompanyIntegrationEntityMapEntity,
@@ -81,6 +83,33 @@ const OVERFLOW_SAMPLE_SIZE = 1000;
81
83
  */
82
84
  const MAX_PROMOTIONS_PER_PASS = 25;
83
85
 
86
+ /** Page size for the full-table overflow walks (spread + stale purge). */
87
+ const OVERFLOW_PAGE_SIZE = 500;
88
+
89
+ /**
90
+ * Rows the stale purge may WRITE in one pass, the row-wise sibling of {@link MAX_PROMOTIONS_PER_PASS}.
91
+ *
92
+ * Each purged row costs one `BaseEntity.Save()` — roughly nine serialized round trips, which is the
93
+ * only write shape MJ offers today (there is no batched-update provider capability; `TransactionGroup`
94
+ * gives atomicity, not batching, since both dialect implementations loop one query per item). That
95
+ * puts the purge around 250 rows/min, so an unbounded sweep of a large table would hold the post-sync
96
+ * promotion callback open for hours.
97
+ *
98
+ * Residue is inert while it waits — the field-map-first terminate check already stops a mapped key
99
+ * being re-offered as a new column — so draining it across several syncs costs nothing but time.
100
+ * The budget bounds WRITES, not the scan: the walk keeps reading (page reads are cheap and rows
101
+ * already purged fall through in memory) so a later pass still reaches residue further down the table.
102
+ */
103
+ const MAX_PURGE_ROWS_PER_PASS = 1000;
104
+
105
+ /** An already-promoted key whose value is still sitting in the staging JSON — residue, not a candidate. */
106
+ interface StaleOverflowKey {
107
+ /** The source field name as it appears inside the overflow JSON. */
108
+ sourceKey: string;
109
+ /** The real column it was promoted to (the active field map's DestinationFieldName). */
110
+ columnName: string;
111
+ }
112
+
84
113
  /** A coverage-passing key with its resolved column + what work it still needs (M4: promote OR recover). */
85
114
  interface WorkItem {
86
115
  candidate: PromotionCandidate;
@@ -92,6 +121,15 @@ interface WorkItem {
92
121
  needsColumn: boolean;
93
122
  /** No active field map for this source key → needs one (covers the partial-promotion crash window). */
94
123
  needsFieldMap: boolean;
124
+ /**
125
+ * Column AND field map already exist, but rows may still hold the value only in the overflow
126
+ * JSON — a run interrupted between ADD COLUMN and the value spread leaves exactly this state,
127
+ * and nothing else ever finishes it: the key is no longer "unmapped" so capture stops, and the
128
+ * old terminate check skipped it as done. A spread-recovery item re-runs ONLY the backfill
129
+ * (no DDL, no metadata writes); the idempotent spread settles it to a no-op once every row is
130
+ * filled. Never surfaced as a UI candidate and never counted as a column added.
131
+ */
132
+ recoverSpread?: boolean;
95
133
  }
96
134
 
97
135
  /** Registers the post-sync custom-column promotion hook on the IntegrationEngine singleton. */
@@ -165,68 +203,239 @@ export class IntegrationCustomColumnPromoter {
165
203
  const integrationID = await this.resolveIntegrationID(companyIntegrationID);
166
204
  if (!integrationID) return NOT_PROMOTED;
167
205
 
168
- const columnsAdded: Array<{ EntityName: string; ColumnName: string }> = [];
206
+ // ── PHASE 1: PLAN build every entity's work list + its RSU input; run NO pipeline. ──
207
+ // The old shape ran the FULL RSU pipeline per entity — a sync touching N entities with
208
+ // candidates paid N CodeGen + compile passes where the batch API exists precisely to pay
209
+ // one. Plan everything first, then promote once.
210
+ interface EntityPlan {
211
+ entityName: string;
212
+ entityInfo: EntityInfo;
213
+ entityMap: { ID: string; ExternalObjectName: string };
214
+ work: WorkItem[];
215
+ /** Index of this entity's input in the RSU batch; -1 when it needs no DDL. */
216
+ batchIndex: number;
217
+ }
218
+ const plans: EntityPlan[] = [];
219
+ const batchInputs: RSUPipelineInput[] = [];
169
220
  for (const entityName of syncedEntityNames) {
170
221
  try {
171
- const added = await this.promoteEntity(companyIntegrationID, integrationID, entityName, customKeyStats?.[entityName]);
172
- columnsAdded.push(...added);
222
+ const planned = await this.planWorkForEntity(companyIntegrationID, entityName, customKeyStats?.[entityName]);
223
+ if (!planned) continue; // no overflow column / no captured customs / no entity map
224
+ const { entityInfo, entityMap } = planned;
225
+
226
+ // PURGE FIRST — strip already-promoted keys from the staging JSON BEFORE any column is
227
+ // created, and before the RSU pass that would restart this process. A failed or skipped
228
+ // sync is not evidence that a column is missing, and leaving the residue in place is what
229
+ // makes an already-promoted key re-surface as a phantom "new column". Runs even when there
230
+ // is nothing new to promote, which is the only way pre-existing residue on rows the sync
231
+ // never rewrites ever gets cleaned rather than endlessly re-detected.
232
+ if (planned.stale.length > 0) {
233
+ await this.purgeStaleOverflowKeys(entityName, entityMap.ID, entityInfo, planned.stale);
234
+ }
235
+ if (planned.work.length === 0) continue; // already converged
236
+ let work = planned.work;
237
+ // M4a: bound schema churn per pass — the remainder stays captured and promotes next sync.
238
+ if (work.length > MAX_PROMOTIONS_PER_PASS) {
239
+ LogStatus(`[CustomColumnPromoter] ${work.length} candidates on ${entityName}; promoting ${MAX_PROMOTIONS_PER_PASS} this pass, ${work.length - MAX_PROMOTIONS_PER_PASS} deferred to next sync.`);
240
+ work = work.slice(0, MAX_PROMOTIONS_PER_PASS);
241
+ }
242
+ const newColumns = work.filter(w => w.needsColumn);
243
+ const plan: EntityPlan = { entityName, entityInfo, entityMap, work, batchIndex: -1 };
244
+ if (newColumns.length > 0) {
245
+ plan.batchIndex = batchInputs.length;
246
+ batchInputs.push(this.buildSchemaInput(entityInfo, newColumns));
247
+ }
248
+ plans.push(plan);
249
+ } catch (err) {
250
+ // One entity's planning failure must not abort the others, and never the sync.
251
+ LogError(`[CustomColumnPromoter] Planning failed for entity '${entityName}': ${this.msg(err)}`);
252
+ }
253
+ }
254
+ if (plans.length === 0) return NOT_PROMOTED;
255
+
256
+ // Register the follow-up DURABLY before the restart, the way the apply-objects path does
257
+ // (IntegrationDiscoveryResolver: `rsuInput.PendingWork = [pendingPayload]`). The restart is
258
+ // what loads the regenerated entity classes, so everything downstream of the DDL — the IOF
259
+ // rows, the field maps, the overflow spread — belongs after it, not before.
260
+ //
261
+ // Carried on the first input because RSU restarts ONCE for the whole batch; the payload
262
+ // describes every entity in the pass.
263
+ if (batchInputs.length > 0) {
264
+ batchInputs[0].PendingWork = [{
265
+ WorkType: 'promote-columns',
266
+ CompanyIntegrationID: companyIntegrationID,
267
+ // Not used by the promote path (which works from PromotedColumns), but the contract
268
+ // requires them and they keep the row legible to an operator reading the table.
269
+ SourceObjectNames: plans.map(p => p.entityMap.ExternalObjectName),
270
+ SchemaName: plans[0].entityInfo.SchemaName,
271
+ CreatedAt: new Date().toISOString(),
272
+ PromotedColumns: plans.map(p => ({
273
+ EntityName: p.entityName,
274
+ EntityMapID: p.entityMap.ID,
275
+ ExternalObjectName: p.entityMap.ExternalObjectName,
276
+ IntegrationID: integrationID,
277
+ Columns: p.work.map(w => ({
278
+ SourceKey: w.sourceKey,
279
+ ColumnName: w.columnName,
280
+ SchemaFieldType: w.candidate.Inferred.SchemaFieldType,
281
+ MaxLength: w.candidate.Inferred.MaxLength,
282
+ Coverage: w.candidate.Coverage,
283
+ })),
284
+ })),
285
+ }];
286
+ batchInputs[0].ContextUser = this.user;
287
+ }
288
+
289
+ // ── PHASE 2: ONE batched RSU pass for ALL entities' ADD COLUMN migrations. ──
290
+ // RunPipelineBatch runs every migration under one lock, then ONE CodeGen + compile +
291
+ // restart + git commit — which is now genuinely one restart and one commit for the whole
292
+ // promotion, rather than one per entity. See buildSchemaInput: the inputs no longer set
293
+ // SkipRestart/SkipGitCommit, so the batch commits the migration and restarts once at the
294
+ // end, which is what exposes the new columns over GraphQL and what keeps the repository in
295
+ // step with the database.
296
+ let batchResults: RSUPipelineResult[] = [];
297
+ if (batchInputs.length > 0) {
298
+ const batch = await RuntimeSchemaManager.Instance.RunPipelineBatch(batchInputs);
299
+ batchResults = batch.Results ?? [];
300
+ // M3: make the freshly-created EntityFields + regenerated sprocs visible in-process
301
+ // ONCE for the whole batch. CRITICAL: the spread below builds sproc calls from this
302
+ // metadata; without the refresh, row.Save() uses the STALE field list that predates
303
+ // the column add and mismatches the regenerated sproc → "Error executing SQL".
304
+ try { await this.provider.Refresh(); } catch (err) { LogError(`[CustomColumnPromoter] post-batch Refresh failed: ${this.msg(err)}`); }
305
+ }
306
+
307
+ // ── PHASE 3: the NO-DDL case only — IOF rows + field maps + value spread, inline. ──
308
+ //
309
+ // An entity that needed a column went through the batch, and the batch RESTARTS: `pm2
310
+ // restart` kills this process, so nothing below runs for it. Its IOF rows, field maps and
311
+ // spread are completed by the post-restart consumer from the PendingWork registered above
312
+ // (CompletePromotion) — which is also where they belong, because only after the restart are
313
+ // the regenerated entity classes loaded.
314
+ //
315
+ // What is left here is the recovery-only pass: work items that need no column, so no
316
+ // migration, so no batch entry and no restart. Those are finished inline exactly as before.
317
+ // Explicitly filtered rather than left to fall through, so this reads as the branch it is
318
+ // instead of as dead code that happens to be unreachable.
319
+ const columnsAdded: Array<{ EntityName: string; ColumnName: string }> = [];
320
+ for (const plan of plans) {
321
+ if (plan.batchIndex >= 0) {
322
+ const res = batchResults[plan.batchIndex];
323
+ if (!res || !res.Success) {
324
+ // DDL failed — leave everything captured; retry next promote (no partial commit
325
+ // lost). The PendingWork row stays Pending and is re-processable.
326
+ LogError(`[CustomColumnPromoter] RSU ADD COLUMN failed on ${plan.entityInfo.Name}: ${res?.ErrorMessage ?? res?.ErrorStep ?? 'unknown'} — leaving ${plan.entityName} captured for retry.`);
327
+ } else {
328
+ // Succeeded. The restart has either already ended this process or is about to,
329
+ // and the consumer owns the follow-up — so do NOT do the metadata work here.
330
+ // Still COUNT the columns: they were promoted, and SchemaUpdatePending is
331
+ // derived from this list. The client keys its "workspace updating" state off
332
+ // that flag, so dropping these would tell it nothing happened.
333
+ const added = plan.work.filter(w => !w.recoverSpread);
334
+ LogStatus(`[CustomColumnPromoter] Promoted ${added.length} column(s) on ${plan.entityName}: ${added.map(w => w.columnName).join(', ')} — metadata + spread deferred to the post-restart consumer.`);
335
+ columnsAdded.push(...added.map(w => ({ EntityName: plan.entityName, ColumnName: w.columnName })));
336
+ }
337
+ continue;
338
+ }
339
+ try {
340
+ // Completion goes through the SAME method the post-restart consumer calls, given the
341
+ // same payload shape. There is one implementation of "finish a promotion", so the
342
+ // no-restart case can never drift from the restart case — which is the drift that
343
+ // would be impossible to notice, since only one of them runs on any given pass.
344
+ await this.CompletePromotion([{
345
+ EntityName: plan.entityName,
346
+ EntityMapID: plan.entityMap.ID,
347
+ ExternalObjectName: plan.entityMap.ExternalObjectName,
348
+ IntegrationID: integrationID,
349
+ Columns: plan.work.map(w => ({
350
+ SourceKey: w.sourceKey,
351
+ ColumnName: w.columnName,
352
+ SchemaFieldType: w.candidate.Inferred.SchemaFieldType,
353
+ MaxLength: w.candidate.Inferred.MaxLength,
354
+ Coverage: w.candidate.Coverage,
355
+ })),
356
+ }], new Map([[plan.entityName, plan.entityInfo]]));
357
+ // recoverSpread items add no column/metadata — they only finish an interrupted
358
+ // backfill, so they don't count as "columns added" (keeps SchemaUpdatePending honest).
359
+ const added = plan.work.filter(w => !w.recoverSpread);
360
+ columnsAdded.push(...added.map(w => ({ EntityName: plan.entityName, ColumnName: w.columnName })));
173
361
  } catch (err) {
174
362
  // One entity's promotion failure must not abort the others, and never the sync.
175
- LogError(`[CustomColumnPromoter] Promotion failed for entity '${entityName}': ${this.msg(err)}`);
363
+ LogError(`[CustomColumnPromoter] Promotion failed for entity '${plan.entityName}': ${this.msg(err)}`);
176
364
  }
177
365
  }
178
366
 
179
367
  const promoted = columnsAdded.length > 0;
180
- if (promoted) {
181
- // Make the freshly-created EntityFields visible in-process for the next sync's mapping.
182
- try { await this.provider.Refresh(); } catch (err) { LogError(`[CustomColumnPromoter] provider.Refresh failed: ${this.msg(err)}`); }
183
- }
184
368
  return { Promoted: promoted, ColumnsAdded: columnsAdded, SchemaUpdatePending: promoted };
185
369
  }
186
370
 
187
- /** Gate → plan → promote for a single target entity. Returns the columns it added. */
188
- private async promoteEntity(
189
- companyIntegrationID: string,
190
- integrationID: string,
191
- entityName: string,
192
- inRunStats?: CustomKeyStat[],
371
+ /**
372
+ * Finish a promotion AFTER the restart — the IntegrationObjectField rows, the field maps and
373
+ * the overflow→column spread — from the {@link RSUPendingWork} the pre-restart pass registered.
374
+ *
375
+ * Same work PHASE 3 does inline when no restart occurred; it lives here rather than in the
376
+ * consumer so promotion logic stays in one class. The difference is that here the regenerated
377
+ * entity classes ARE loaded, so the spread writes through real typed columns instead of the
378
+ * dynamic .Get/.Set the pre-restart path is forced into.
379
+ *
380
+ * Idempotent, because a pending row is marked Completed only after success and so stays
381
+ * re-processable after a crash: field maps are filtered against those already active, and the
382
+ * spread only fills a destination that is still empty.
383
+ */
384
+ public async CompletePromotion(
385
+ promoted: NonNullable<RSUPendingWork['PromotedColumns']>,
386
+ knownEntities?: ReadonlyMap<string, EntityInfo>,
193
387
  ): Promise<Array<{ EntityName: string; ColumnName: string }>> {
194
- const planned = await this.planWorkForEntity(companyIntegrationID, entityName, inRunStats);
195
- if (!planned || planned.work.length === 0) return []; // no overflow / no entity map / already converged
196
- const { entityInfo, entityMap } = planned;
197
- let work = planned.work;
198
-
199
- // M4a: bound schema churn per pass — the remainder stays captured and promotes next sync.
200
- if (work.length > MAX_PROMOTIONS_PER_PASS) {
201
- LogStatus(`[CustomColumnPromoter] ${work.length} candidates on ${entityName}; promoting ${MAX_PROMOTIONS_PER_PASS} this pass, ${work.length - MAX_PROMOTIONS_PER_PASS} deferred to next sync.`);
202
- work = work.slice(0, MAX_PROMOTIONS_PER_PASS);
203
- }
388
+ const columnsAdded: Array<{ EntityName: string; ColumnName: string }> = [];
389
+ for (const entry of promoted) {
390
+ try {
391
+ // Provider first — post-restart it holds the REGENERATED class, which is the whole
392
+ // reason completion was deferred. knownEntities is the inline caller handing back the
393
+ // EntityInfo it already resolved, for the no-DDL pass where nothing was regenerated.
394
+ const entityInfo = this.provider.EntityByName(entry.EntityName) ?? knownEntities?.get(entry.EntityName);
395
+ if (!entityInfo) {
396
+ LogError(`[CustomColumnPromoter] Post-restart: entity '${entry.EntityName}' not found; skipping.`);
397
+ continue;
398
+ }
399
+ // Destination names are CARRIED, never recomputed: uniqueColumnName may have
400
+ // suffixed one to dodge a collision, and re-deriving it here could pick a different
401
+ // name than the column the migration actually created.
402
+ const existingMaps = await this.activeFieldMaps(entry.EntityMapID);
403
+ const named: WorkItem[] = entry.Columns.map(c => ({
404
+ candidate: {
405
+ Key: c.SourceKey,
406
+ Coverage: c.Coverage,
407
+ // SqlServerType/PostgresType drive DDL only, which ran before the restart.
408
+ // The phase-3 work below reads SchemaFieldType and MaxLength.
409
+ Inferred: {
410
+ SchemaFieldType: c.SchemaFieldType,
411
+ MaxLength: c.MaxLength,
412
+ SqlServerType: '',
413
+ PostgresType: '',
414
+ },
415
+ } as unknown as PromotionCandidate,
416
+ sourceKey: c.SourceKey,
417
+ columnName: c.ColumnName,
418
+ needsColumn: false,
419
+ needsFieldMap: !existingMaps.has(c.SourceKey.toLowerCase()),
420
+ }));
421
+
422
+ await this.createIntegrationObjectFields(entry.IntegrationID, entry.ExternalObjectName, named);
423
+ await this.createFieldMaps(entry.EntityMapID, named.filter(n => n.needsFieldMap));
424
+ await this.spreadAndRebaseline(entry.EntityName, entry.EntityMapID, entityInfo, named);
204
425
 
205
- // 4. PROMOTE: ADD COLUMN+CodeGen (only the keys that need a column) IOF rows field maps → spread.
206
- const newColumns = work.filter(w => w.needsColumn);
207
- if (newColumns.length > 0 && !await this.applySchemaChange(entityInfo, newColumns)) {
208
- return []; // DDL failed leave everything captured; retry next sync (no partial commit lost)
426
+ LogStatus(`[CustomColumnPromoter] Post-restart: completed ${named.length} promoted column(s) on ${entry.EntityName}: ${named.map(n => n.columnName).join(', ')}`);
427
+ columnsAdded.push(...named.map(n => ({ EntityName: entry.EntityName, ColumnName: n.columnName })));
428
+ } catch (err) {
429
+ // One entity must not abort the others; the pending row stays open for retry.
430
+ LogError(`[CustomColumnPromoter] Post-restart completion failed for '${entry.EntityName}': ${this.msg(err)}`);
431
+ }
209
432
  }
210
- await this.createIntegrationObjectFields(integrationID, entityMap.ExternalObjectName, work);
211
- await this.createFieldMaps(entityMap.ID, work.filter(w => w.needsFieldMap));
212
- // M3: spread staged values into the real columns + re-baseline the content hash.
213
- // CRITICAL: refresh metadata FIRST. applySchemaChange just added the columns AND had RSU
214
- // regenerate the spUpdate sproc to include them — but the running provider's in-memory entity
215
- // metadata still predates the column add (the Refresh at the top ran before it). Without this,
216
- // the spread's row.Save() builds a sproc call from the STALE field list that doesn't match the
217
- // regenerated sproc → "Error executing SQL" (the spread-save failures). Re-loading metadata
218
- // here aligns the entity's field set with the new DB sproc so the backfill saves succeed.
219
- try { await this.provider.Refresh(); } catch (err) { LogError(`[CustomColumnPromoter] pre-spread Refresh failed: ${this.msg(err)}`); }
220
- const refreshedEntityInfo = this.provider.EntityByName(entityName) ?? entityInfo;
221
- await this.spreadAndRebaseline(entityName, entityMap.ID, refreshedEntityInfo, work);
222
-
223
- LogStatus(`[CustomColumnPromoter] Promoted/recovered ${work.length} custom column(s) on ${entityName}: ${work.map(w => w.columnName).join(', ')}`);
224
- return work.map(w => ({ EntityName: entityName, ColumnName: w.columnName }));
433
+ return columnsAdded;
225
434
  }
226
435
 
227
436
  /**
228
437
  * Dry-run of GATE → scan → PLAN → resolve-work for ONE entity, WITHOUT applying any schema change.
229
- * Shared by {@link promoteEntity} (which then PROMOTES the work) and {@link ListCandidates} (which only
438
+ * Shared by {@link PromoteForSync} (phase 1, which then batch-PROMOTES the work) and {@link ListCandidates} (which only
230
439
  * reports it). Returns null when the entity has no overflow column / no captured customs / no entity map.
231
440
  * Because the work list is computed live (overflow keys minus already-column-and-mapped), re-running is
232
441
  * inherently deduped — a concurrent discovery that already promoted a key yields no work item for it.
@@ -235,7 +444,7 @@ export class IntegrationCustomColumnPromoter {
235
444
  companyIntegrationID: string,
236
445
  entityName: string,
237
446
  inRunStats?: CustomKeyStat[],
238
- ): Promise<{ entityInfo: EntityInfo; entityMap: { ID: string; ExternalObjectName: string }; work: WorkItem[] } | null> {
447
+ ): Promise<{ entityInfo: EntityInfo; entityMap: { ID: string; ExternalObjectName: string }; work: WorkItem[]; stale: StaleOverflowKey[] } | null> {
239
448
  const entityInfo = this.provider.EntityByName(entityName);
240
449
  if (!entityInfo?.SchemaName || !entityInfo.BaseTable) return null;
241
450
  // No overflow column on this table (predates the feature) → nothing to promote.
@@ -247,6 +456,13 @@ export class IntegrationCustomColumnPromoter {
247
456
  // out-of-band capture; the hash basis excludes overflow so skips never write it),
248
457
  // 3. candidates persisted from prior runs (survive restarts for on-demand listing).
249
458
  const overflowJson = await this.scanOverflow(entityName);
459
+ // Every key literally present in the staging JSON right now. Kept separate from the candidate
460
+ // set below, which drops low-coverage keys — a stale key must be purged regardless of coverage.
461
+ const liveOverflowKeys = new Set<string>();
462
+ for (const raw of overflowJson) {
463
+ const parsed = this.parseOverflow(raw);
464
+ if (parsed) for (const k of Object.keys(parsed)) liveOverflowKeys.add(k);
465
+ }
250
466
 
251
467
  // U3 note (rkihm-BC review, #3061): this in-repo promotion path passes no `LockUntilFullSync`, so it
252
468
  // does NOT yet enforce "hold promotion until a full sync since the last schema change." The lever
@@ -259,7 +475,10 @@ export class IntegrationCustomColumnPromoter {
259
475
  for (const c of this.candidatesFromStats(inRunStats)) if (!byKey.has(c.Key)) byKey.set(c.Key, c);
260
476
  for (const c of await this.loadPersistedCandidates(companyIntegrationID, entityName)) if (!byKey.has(c.Key)) byKey.set(c.Key, c);
261
477
  const passing = [...byKey.values()];
262
- if (passing.length === 0) return null;
478
+ // Nothing captured AND nothing staged — genuinely nothing to look at. A key can be staged
479
+ // without being a candidate (coverage filtered it out), and that key still needs purging, so
480
+ // this deliberately does NOT return early on `passing.length === 0` alone.
481
+ if (passing.length === 0 && liveOverflowKeys.size === 0) return null;
263
482
 
264
483
  const entityMap = await this.findEntityMap(companyIntegrationID, entityName);
265
484
  if (!entityMap) {
@@ -268,9 +487,19 @@ export class IntegrationCustomColumnPromoter {
268
487
  }
269
488
 
270
489
  // Skip fully-terminated keys (column + field map both exist); keep promote (needs column) / recover.
271
- const fieldMapSources = await this.activeFieldMapSources(entityMap.ID);
272
- const work = this.resolveWorkItems(passing, entityInfo, fieldMapSources);
273
- return { entityInfo, entityMap, work };
490
+ const fieldMaps = await this.activeFieldMaps(entityMap.ID);
491
+ const work = this.resolveWorkItems(passing, entityInfo, fieldMaps);
492
+
493
+ // Keys that are ALREADY promoted (active field map) yet whose value is still sitting in the
494
+ // staging JSON. These are not candidates — they are residue, and they cannot clear themselves:
495
+ // the next sync only rewrites a row whose content hash changed, and the hash basis excludes the
496
+ // overflow column, so a row untouched since before the promotion keeps the key indefinitely.
497
+ const stale: StaleOverflowKey[] = [];
498
+ for (const key of liveOverflowKeys) {
499
+ const columnName = fieldMaps.get(key.toLowerCase());
500
+ if (columnName) stale.push({ sourceKey: key, columnName });
501
+ }
502
+ return { entityInfo, entityMap, work, stale };
274
503
  }
275
504
 
276
505
  /**
@@ -284,7 +513,9 @@ export class IntegrationCustomColumnPromoter {
284
513
  ): Promise<Array<{ EntityName: string; SourceKey: string; ColumnName: string; InferredType: string; NeedsColumn: boolean }>> {
285
514
  const planned = await this.planWorkForEntity(companyIntegrationID, entityName);
286
515
  if (!planned) return [];
287
- return planned.work.map(w => ({
516
+ // recoverSpread items are not "new columns found" — they are an internal backfill-recovery
517
+ // signal (column + field map already exist), so they must not surface as UI candidates.
518
+ return planned.work.filter(w => !w.recoverSpread).map(w => ({
288
519
  EntityName: entityName,
289
520
  SourceKey: w.sourceKey,
290
521
  ColumnName: w.columnName,
@@ -401,16 +632,32 @@ export class IntegrationCustomColumnPromoter {
401
632
  private resolveWorkItems(
402
633
  passing: PromotionCandidate[],
403
634
  entityInfo: EntityInfo,
404
- fieldMapSources: ReadonlySet<string>,
635
+ fieldMaps: ReadonlyMap<string, string>,
405
636
  ): WorkItem[] {
406
637
  const existingByLower = new Map(entityInfo.Fields.map(f => [f.Name.toLowerCase(), f.Name]));
407
638
  const taken = new Set(entityInfo.Fields.map(f => f.Name.toLowerCase()));
408
639
  const items: WorkItem[] = [];
409
640
  for (const candidate of passing) {
410
- const existingCol = existingByLower.get(sanitizeColumnName(candidate.Key).toLowerCase());
641
+ // An ACTIVE field map is the authoritative proof that this key was already promoted, and it
642
+ // names the column that was created. Trust it ahead of a re-sanitized guess against the
643
+ // in-memory field list: that list can predate the ADD COLUMN in THIS process (the promoter
644
+ // refreshes its own provider, not every other one), and the real column may carry a
645
+ // collision suffix the guess cannot reproduce. Both misses used to read as "no column yet",
646
+ // re-offering an already-promoted key to the operator as a brand-new column — and, on
647
+ // promotion, minting a duplicate `_2` column beside the working one.
648
+ const mappedDest = fieldMaps.get(candidate.Key.toLowerCase());
649
+ const hasFieldMap = mappedDest !== undefined;
650
+ const existingCol =
651
+ (mappedDest ? existingByLower.get(mappedDest.toLowerCase()) ?? mappedDest : undefined) ??
652
+ existingByLower.get(sanitizeColumnName(candidate.Key).toLowerCase());
411
653
  const hasColumn = !!existingCol;
412
- const hasFieldMap = fieldMapSources.has(candidate.Key.toLowerCase());
413
- if (hasColumn && hasFieldMap) continue; // terminated — nothing to do
654
+ if (hasColumn && hasFieldMap) {
655
+ // Schema + mapping are terminated — but see WorkItem.recoverSpread: an interrupted
656
+ // spread leaves rows carrying the value only in overflow, and this was the one exit
657
+ // that made that state permanent. Keep it as a spread-recovery item instead.
658
+ items.push({ candidate, sourceKey: candidate.Key, columnName: existingCol!, needsColumn: false, needsFieldMap: false, recoverSpread: true });
659
+ continue;
660
+ }
414
661
  const columnName = existingCol ?? this.uniqueColumnName(sanitizeColumnName(candidate.Key), taken);
415
662
  if (!hasColumn) taken.add(columnName.toLowerCase());
416
663
  items.push({ candidate, sourceKey: candidate.Key, columnName, needsColumn: !hasColumn, needsFieldMap: !hasFieldMap });
@@ -418,16 +665,32 @@ export class IntegrationCustomColumnPromoter {
418
665
  return items;
419
666
  }
420
667
 
421
- /** Active field-map SOURCE field names for an entity map (lowercased) — for the terminate/recovery check. */
422
- private async activeFieldMapSources(entityMapID: string): Promise<ReadonlySet<string>> {
668
+ /**
669
+ * Active field maps for an entity map, as `lowercased SourceFieldName -> DestinationFieldName`.
670
+ *
671
+ * The destination name is what makes this authoritative: it records the column promotion ACTUALLY
672
+ * created, which may carry a collision suffix (`_2`) that re-sanitizing the source key can never
673
+ * reproduce. One query serves the terminate/recovery check, the stale-key detection and the hash
674
+ * re-baseline, where there used to be two identical queries and a lossier projection.
675
+ */
676
+ private async activeFieldMaps(entityMapID: string): Promise<ReadonlyMap<string, string>> {
423
677
  const rv = new RunView();
424
678
  const res = await rv.RunView<MJCompanyIntegrationFieldMapEntity>({
425
679
  EntityName: 'MJ: Company Integration Field Maps',
426
680
  ExtraFilter: `EntityMapID='${entityMapID}' AND Status='Active'`,
427
- Fields: ['SourceFieldName'],
681
+ Fields: ['SourceFieldName', 'DestinationFieldName'],
428
682
  ResultType: 'simple',
429
683
  }, this.user);
430
- return new Set(res.Success ? (res.Results ?? []).map(r => (r.SourceFieldName ?? '').toLowerCase()) : []);
684
+ const maps = new Map<string, string>();
685
+ if (res.Success) {
686
+ for (const r of res.Results ?? []) {
687
+ const source = (r.SourceFieldName ?? '').toLowerCase();
688
+ // '' destination keeps the key MAPPED for the terminate check while signalling that no
689
+ // column name is recoverable from it — a `has()` miss and a `get()` of '' are different answers.
690
+ if (source) maps.set(source, r.DestinationFieldName ?? '');
691
+ }
692
+ }
693
+ return maps;
431
694
  }
432
695
 
433
696
  /** Suffixes _2, _3, … until the sanitized name does not collide with an existing/assigned one. */
@@ -440,8 +703,12 @@ export class IntegrationCustomColumnPromoter {
440
703
  return `${base}_${Date.now() % 100000}`; // pathological fallback (never expected)
441
704
  }
442
705
 
443
- /** Generates ADD COLUMN DDL for the new columns and runs it through RSU (CodeGen reflects them). */
444
- private async applySchemaChange(entityInfo: EntityInfo, named: WorkItem[]): Promise<boolean> {
706
+ /**
707
+ * Builds the RSUPipelineInput (ADD COLUMN DDL) for one entity's new columns — pure, runs NO
708
+ * pipeline. PromoteForSync collects these across all entities and runs ONE RunPipelineBatch so
709
+ * the whole promote is a single CodeGen/compile/restart pass.
710
+ */
711
+ private buildSchemaInput(entityInfo: EntityInfo, named: WorkItem[]): RSUPipelineInput {
445
712
  const platform = this.dbProvider.PlatformKey as DatabasePlatform;
446
713
  const ddl = new DDLGenerator();
447
714
  const statements = named.map(n =>
@@ -452,20 +719,44 @@ export class IntegrationCustomColumnPromoter {
452
719
  platform,
453
720
  ),
454
721
  );
455
- const input: RSUPipelineInput = {
722
+ return {
456
723
  MigrationSQL: statements.join('\n'),
457
724
  Description: `Promote ${named.length} custom column(s) on ${entityInfo.Name}`,
458
725
  AffectedTables: [`${entityInfo.SchemaName}.${entityInfo.BaseTable}`],
459
- // M3 owns the restart-signal + restart; this stage applies schema only. Runtime
460
- // promotion creates no git commit (the dev RSU flow does; a per-sync commit is noise).
461
- SkipRestart: true,
462
- SkipGitCommit: true,
726
+ // Neither SkipRestart nor SkipGitCommit is set, so RSU runs its normal pipeline. Both
727
+ // are optional and RSU gates on `!inputs.every(i => i.SkipGitCommit)`, so omitting them
728
+ // IS the default — no caller or platform change is needed to get commit + restart.
729
+ //
730
+ // `pm2 restart` kills this process, so PHASE 3 below does NOT run when the restart
731
+ // happens. That is why the follow-up is registered as PendingWork above: the
732
+ // post-restart consumer completes it, with the regenerated entity classes actually
733
+ // loaded. PHASE 3 remains as the fallback for the case where no DDL was needed (no
734
+ // batch, so no restart) — mirroring the apply path, which likewise finishes inline only
735
+ // when the restart did not occur.
736
+ //
737
+ // Both flags were previously hardcoded true here, the only place in the repo either was
738
+ // forced rather than passed in. Every integration entry point takes them as arguments
739
+ // defaulting to false, so add/remove tables, refresh schema and first-time setup all
740
+ // commit and restart; promotion was the outlier. Without the restart the columns never
741
+ // reached GraphQL — "already usable (metadata refreshed)" conflated metadata with CODE,
742
+ // since Refresh() reloads EntityField rows but does not load regenerated classes into a
743
+ // running process. Without the commit the database carried columns git had no record of.
744
+
745
+ // SkipGitCommit, by contrast, was wrong and is now dropped, so the migration and the
746
+ // regenerated code reach the repository. It is the only place in the repo either flag
747
+ // was forced rather than passed in; every integration entry point takes them as
748
+ // arguments defaulting to false, so add/remove tables, refresh schema and first-time
749
+ // setup all commit already. Without the commit the database carries columns git has no
750
+ // record of — observed live, where a workspace's promoted columns were present only
751
+ // because a LATER schema refresh happened to re-emit them as ADD COLUMN IF NOT EXISTS.
752
+ // The commit does not touch the process, so it is safe where the restart is not.
753
+ //
754
+ // The restart itself remains genuinely unsolved: it has to happen AFTER phase 3, and
755
+ // nothing performs it — `sync.schema_update` carries restartRequiredForGraphQL: true
756
+ // and has no subscriber, while the client arms its RSU poll and waits for a restart
757
+ // that never comes. Until that is closed, the columns do not reach GraphQL. Deliberately
758
+ // NOT fixed by restarting here, which would break promotion outright.
463
759
  };
464
- const result = await RuntimeSchemaManager.Instance.RunPipeline(input);
465
- if (!result.Success) {
466
- LogError(`[CustomColumnPromoter] RSU ADD COLUMN failed on ${entityInfo.Name}: ${result.ErrorMessage ?? result.ErrorStep ?? 'unknown'}`);
467
- }
468
- return result.Success;
469
760
  }
470
761
 
471
762
  /** Builds a per-platform TargetColumnConfig from a planned candidate. */
@@ -565,10 +856,10 @@ export class IntegrationCustomColumnPromoter {
565
856
  /**
566
857
  * Spreads the staged overflow JSON values into the freshly-created real columns, then
567
858
  * re-baselines the content hash (gaps.md §2 step 3). JS per-row pass — dialect-agnostic (no
568
- * cast SQL, which is where PG bugs hide); BaseEntity handles the dialect on write. The overflow
569
- * column is intentionally NOT cleared: once the field map exists the key is no longer "unmapped",
570
- * so the next sync stops re-capturing it and planPromotions skips the now-existing column — the
571
- * stale value self-heals. Bounded to rows that carry overflow, paged, once on the discovery sync.
859
+ * cast SQL, which is where PG bugs hide); BaseEntity handles the dialect on write. Each promoted
860
+ * key is also STRIPPED from the staging JSON as it is spread: it is no longer unmapped, and
861
+ * leaving it behind is what later re-surfaces it as a phantom new column (see
862
+ * {@link purgeStaleOverflowKeys}). Bounded to rows that carry overflow, paged.
572
863
  */
573
864
  private async spreadAndRebaseline(
574
865
  entityName: string,
@@ -578,51 +869,186 @@ export class IntegrationCustomColumnPromoter {
578
869
  ): Promise<void> {
579
870
  const hasHashCol = entityInfo.Fields.some(f => f.Name === CONTENT_HASH_COLUMN);
580
871
  const mappedDestFields = hasHashCol ? await this.activeDestinationFields(entityMapID) : [];
872
+ await this.forEachOverflowRow(entityName, entityInfo, row =>
873
+ this.spreadOneRow(row, named, hasHashCol, mappedDestFields),
874
+ );
875
+ }
581
876
 
582
- let startRow = 0;
583
- const pageSize = 500;
877
+ /**
878
+ * Removes already-promoted keys from the staging JSON across the WHOLE table, spreading each
879
+ * value into its real column first if that column is still empty (a row promoted before the
880
+ * spread ever reached it must not lose the value on the way out).
881
+ *
882
+ * This exists because the residue cannot self-heal. The sync rewrites a row only when its content
883
+ * hash changes, and the hash basis deliberately excludes the overflow column — so a row that has
884
+ * not changed since before the promotion is never rewritten, keeps the promoted key forever, and
885
+ * keeps that key showing up in the candidate listing as a new column to add.
886
+ */
887
+ private async purgeStaleOverflowKeys(
888
+ entityName: string,
889
+ entityMapID: string,
890
+ entityInfo: EntityInfo,
891
+ stale: StaleOverflowKey[],
892
+ ): Promise<void> {
893
+ const columns = new Set(entityInfo.Fields.map(f => f.Name.toLowerCase()));
894
+ const purgeable = stale.filter(k => columns.has(k.columnName.toLowerCase()));
895
+ if (purgeable.length === 0) {
896
+ // Mapped but no such column in this process's metadata: the map may be newer than the
897
+ // metadata, so DON'T strip the staged value — that would be the only copy of it.
898
+ LogStatus(`[CustomColumnPromoter] ${stale.length} mapped key(s) on ${entityName} have no column in current metadata; purge skipped this pass.`);
899
+ return;
900
+ }
901
+ // Same re-baseline as the spread: the promoted columns are already in the field maps, so they
902
+ // are already part of what the next sync hashes. Backfilling one without re-baselining would
903
+ // leave every purged row hash-mismatched and force a needless rewrite on the next sync.
904
+ const hasHashCol = entityInfo.Fields.some(f => f.Name === CONTENT_HASH_COLUMN);
905
+ const mappedDestFields = hasHashCol ? await this.activeDestinationFields(entityMapID) : [];
906
+ let written = 0;
907
+ let removedFromSet = 0;
908
+ await this.forEachOverflowRow(
909
+ entityName,
910
+ entityInfo,
911
+ async row => {
912
+ const result = await this.purgeOneRow(row, purgeable, hasHashCol, mappedDestFields);
913
+ if (result.wrote) written++;
914
+ if (result.leftSet) removedFromSet++;
915
+ return result.leftSet;
916
+ },
917
+ () => written >= MAX_PURGE_ROWS_PER_PASS,
918
+ );
919
+ const keys = purgeable.map(k => k.sourceKey).join(', ');
920
+ if (written >= MAX_PURGE_ROWS_PER_PASS) {
921
+ LogStatus(`[CustomColumnPromoter] Purged ${keys} from ${written} row(s) on ${entityName} — per-pass budget reached; any remaining residue is purged on the next sync.`);
922
+ } else {
923
+ LogStatus(`[CustomColumnPromoter] Purged ${keys} from the staging JSON of ${written} row(s) on ${entityName} (already promoted).`);
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Walks every row of `entityName` whose overflow JSON is non-null, in pages, applying `visit`.
929
+ *
930
+ * `visit` reports whether the row LEFT the filtered set (its JSON went null). Those removals shift
931
+ * every later row toward the front, so the offset advances by rows-seen-minus-rows-removed rather
932
+ * than by page size — otherwise the walk skips exactly as many rows as it cleans. Ordering is
933
+ * pinned to the primary key so the offsets refer to a stable sequence across the paged queries.
934
+ *
935
+ * `stop` is checked before each row and ends the walk early — a write budget, not a scan limit,
936
+ * so a capped caller still reads past rows it has nothing to do on. See {@link MAX_PURGE_ROWS_PER_PASS}.
937
+ */
938
+ private async forEachOverflowRow(
939
+ entityName: string,
940
+ entityInfo: EntityInfo,
941
+ visit: (row: BaseEntity) => Promise<boolean>,
942
+ stop?: () => boolean,
943
+ ): Promise<void> {
944
+ const orderBy = entityInfo.PrimaryKeys.map(pk => pk.Name).join(', ');
945
+ let seen = 0;
946
+ let removed = 0;
584
947
  for (;;) {
585
948
  const rv = new RunView();
586
949
  const res = await rv.RunView<BaseEntity>({
587
950
  EntityName: entityName,
588
951
  ExtraFilter: `${CUSTOM_OVERFLOW_COLUMN} IS NOT NULL`,
952
+ OrderBy: orderBy.length > 0 ? orderBy : undefined,
589
953
  ResultType: 'entity_object',
590
- MaxRows: pageSize,
591
- StartRow: startRow,
954
+ MaxRows: OVERFLOW_PAGE_SIZE,
955
+ StartRow: seen - removed,
592
956
  }, this.user);
593
957
  if (!res.Success) {
594
- LogError(`[CustomColumnPromoter] Spread scan failed for ${entityName}: ${res.ErrorMessage}`);
958
+ LogError(`[CustomColumnPromoter] Overflow row scan failed for ${entityName}: ${res.ErrorMessage}`);
595
959
  return;
596
960
  }
597
961
  const rows = res.Results ?? [];
598
962
  for (const row of rows) {
599
- await this.spreadOneRow(row, named, hasHashCol, mappedDestFields);
963
+ if (stop?.()) return;
964
+ if (await visit(row)) removed++;
965
+ seen++;
600
966
  }
601
- if (rows.length < pageSize) break;
602
- startRow += pageSize;
967
+ if (rows.length < OVERFLOW_PAGE_SIZE) break;
968
+ }
969
+ }
970
+
971
+ /**
972
+ * Strips the given already-promoted keys from one row's staging JSON, backfilling any real column
973
+ * that is still empty.
974
+ *
975
+ * Reports both facts the caller needs and they are NOT the same: `wrote` is what the per-pass
976
+ * budget spends (one Save), `leftSet` is whether the row dropped out of the `IS NOT NULL` filter
977
+ * and so shifts the paged offsets. A row that keeps other unmapped keys is written but stays.
978
+ */
979
+ private async purgeOneRow(
980
+ row: BaseEntity,
981
+ stale: StaleOverflowKey[],
982
+ hasHashCol: boolean,
983
+ mappedDestFields: string[],
984
+ ): Promise<{ wrote: boolean; leftSet: boolean }> {
985
+ const overflow = this.parseOverflow(row.Get(CUSTOM_OVERFLOW_COLUMN));
986
+ if (!overflow) return { wrote: false, leftSet: false };
987
+ let changed = false;
988
+ for (const k of stale) {
989
+ if (!Object.prototype.hasOwnProperty.call(overflow, k.sourceKey)) continue;
990
+ const current = row.Get(k.columnName);
991
+ if (current === null || current === undefined) row.Set(k.columnName, overflow[k.sourceKey]);
992
+ delete overflow[k.sourceKey];
993
+ changed = true;
994
+ }
995
+ // Nothing of ours in this row — a pure read, so it costs no budget. This is the case that makes
996
+ // a capped pass still able to reach residue further down: rows an earlier pass already cleaned
997
+ // fall through here instead of being re-written.
998
+ if (!changed) return { wrote: false, leftSet: false };
999
+ const remaining = Object.keys(overflow).length > 0 ? JSON.stringify(overflow) : null;
1000
+ row.Set(CUSTOM_OVERFLOW_COLUMN, remaining);
1001
+ if (hasHashCol) {
1002
+ const mapped: Record<string, unknown> = {};
1003
+ for (const dest of mappedDestFields) mapped[dest] = row.Get(dest);
1004
+ row.Set(CONTENT_HASH_COLUMN, computeContentHash(mapped));
603
1005
  }
1006
+ if (!await row.Save()) {
1007
+ LogError(`[CustomColumnPromoter] Overflow purge save failed: ${row.LatestResult?.CompleteMessage ?? 'unknown'}`);
1008
+ return { wrote: false, leftSet: false };
1009
+ }
1010
+ return { wrote: true, leftSet: remaining === null };
604
1011
  }
605
1012
 
606
- /** Applies the staged values + re-baselined hash to a single row entity and saves it. */
1013
+ /**
1014
+ * Applies the staged values + re-baselined hash to a single row entity and saves it. Returns true
1015
+ * when the row's overflow JSON went null (it left the filtered set — see {@link forEachOverflowRow}).
1016
+ */
607
1017
  private async spreadOneRow(
608
1018
  row: BaseEntity,
609
1019
  named: WorkItem[],
610
1020
  hasHashCol: boolean,
611
1021
  mappedDestFields: string[],
612
- ): Promise<void> {
1022
+ ): Promise<boolean> {
613
1023
  // Dynamic .Get/.Set is REQUIRED here: these columns were created at runtime and have no
614
1024
  // generated typed property in this still-running process (full typed access arrives on the
615
1025
  // post-promotion restart). This is the sanctioned exception to the no-.Get/.Set rule.
616
1026
  const overflow = this.parseOverflow(row.Get(CUSTOM_OVERFLOW_COLUMN));
617
- if (!overflow) return;
1027
+ if (!overflow) return false;
618
1028
  let changed = false;
619
1029
  for (const n of named) {
620
1030
  if (Object.prototype.hasOwnProperty.call(overflow, n.sourceKey)) {
621
- row.Set(n.columnName, overflow[n.sourceKey]);
1031
+ // Idempotent spread: only WRITE when the destination is still unset. A freshly-added
1032
+ // column is null on first spread (so it fills); an already-backfilled column is left
1033
+ // alone, so a re-run after an interrupted spread finishes the gap without overwriting
1034
+ // a settled value.
1035
+ const current = row.Get(n.columnName);
1036
+ if (current === null || current === undefined) {
1037
+ row.Set(n.columnName, overflow[n.sourceKey]);
1038
+ }
1039
+ // The STRIP is unconditional, and deliberately outside the guard above: the key now has
1040
+ // a real column and an active field map, so it is no longer unmapped whether or not this
1041
+ // pass was the one that filled it. Leaving it behind relies on a later sync to evict it,
1042
+ // which never happens for a row whose content hash does not change (the hash basis
1043
+ // excludes this column). Stripping is what makes the recovery pass converge — once the
1044
+ // key is gone from the JSON the row drops out of the scan entirely.
1045
+ delete overflow[n.sourceKey];
622
1046
  changed = true;
623
1047
  }
624
1048
  }
625
- if (!changed) return;
1049
+ if (!changed) return false;
1050
+ const remaining = Object.keys(overflow).length > 0 ? JSON.stringify(overflow) : null;
1051
+ row.Set(CUSTOM_OVERFLOW_COLUMN, remaining);
626
1052
  if (hasHashCol) {
627
1053
  // Re-baseline to the next-sync value: hash over all active mapped destination columns
628
1054
  // (now incl. the new ones) as they sit on the row — matches what the next sync computes.
@@ -632,21 +1058,17 @@ export class IntegrationCustomColumnPromoter {
632
1058
  }
633
1059
  if (!await row.Save()) {
634
1060
  LogError(`[CustomColumnPromoter] Spread save failed: ${row.LatestResult?.CompleteMessage ?? 'unknown'}`);
1061
+ return false;
635
1062
  }
1063
+ return remaining === null;
636
1064
  }
637
1065
 
638
1066
  /** Active field-map destination column names for an entity map (for hash re-baseline). */
639
1067
  private async activeDestinationFields(entityMapID: string): Promise<string[]> {
640
- const rv = new RunView();
641
- const res = await rv.RunView<MJCompanyIntegrationFieldMapEntity>({
642
- EntityName: 'MJ: Company Integration Field Maps',
643
- ExtraFilter: `EntityMapID='${entityMapID}' AND Status='Active'`,
644
- Fields: ['DestinationFieldName'],
645
- ResultType: 'simple',
646
- }, this.user);
647
- return res.Success ? (res.Results ?? []).map(r => r.DestinationFieldName).filter(Boolean) : [];
1068
+ return [...(await this.activeFieldMaps(entityMapID)).values()].filter(Boolean);
648
1069
  }
649
1070
 
1071
+
650
1072
  private parseOverflow(raw: unknown): Record<string, unknown> | null {
651
1073
  if (typeof raw !== 'string' || raw.length === 0) return null;
652
1074
  try {