@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.
- package/LICENSE +180 -4
- package/dist/agentSessions/SessionManager.d.ts +19 -5
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +27 -9
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.js +69 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
- package/dist/auth/AuthProviderEngine.d.ts +96 -0
- package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
- package/dist/auth/AuthProviderEngine.js +245 -0
- package/dist/auth/AuthProviderEngine.js.map +1 -0
- package/dist/auth/index.d.ts +12 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +57 -13
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/initializeProviders.d.ts +64 -1
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +117 -5
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
- package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
- package/dist/auth/scopeLimitedPrincipal.js +49 -0
- package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
- package/dist/config.d.ts +89 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +52 -35
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +21 -1
- package/dist/context.js.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
- package/dist/generated/generated.d.ts +619 -1597
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +3341 -17021
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +28 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +57 -8
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +4 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +31 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +147 -14
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +79 -15
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +472 -97
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/integration/InactiveRowWarnings.d.ts +48 -0
- package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
- package/dist/integration/InactiveRowWarnings.js +57 -0
- package/dist/integration/InactiveRowWarnings.js.map +1 -0
- package/dist/integration/RSUProgressBridge.d.ts +83 -0
- package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
- package/dist/integration/RSUProgressBridge.js +230 -0
- package/dist/integration/RSUProgressBridge.js.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts +62 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.js +67 -0
- package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
- package/dist/providerConfigUnits.d.ts +20 -0
- package/dist/providerConfigUnits.d.ts.map +1 -0
- package/dist/providerConfigUnits.js +22 -0
- package/dist/providerConfigUnits.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +20 -6
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +34 -4
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +218 -28
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
- package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
- package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
- package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +108 -3
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +374 -87
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +5 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +33 -14
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +20 -7
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/UserResolver.d.ts +19 -17
- package/dist/resolvers/UserResolver.d.ts.map +1 -1
- package/dist/resolvers/UserResolver.js +66 -67
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/resolvers/currentUserRoles.d.ts +19 -0
- package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
- package/dist/resolvers/currentUserRoles.js +36 -0
- package/dist/resolvers/currentUserRoles.js.map +1 -0
- package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.js +3 -6
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
- package/dist/rest/MediaAccessKeys.d.ts +17 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
- package/dist/rest/MediaAccessKeys.js +42 -1
- package/dist/rest/MediaAccessKeys.js.map +1 -1
- package/dist/rest/MediaStreamHandler.d.ts +3 -2
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
- package/dist/rest/MediaStreamHandler.js +83 -2
- package/dist/rest/MediaStreamHandler.js.map +1 -1
- package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +129 -43
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/rest/UploadTokenManager.d.ts +90 -0
- package/dist/rest/UploadTokenManager.d.ts.map +1 -0
- package/dist/rest/UploadTokenManager.js +156 -0
- package/dist/rest/UploadTokenManager.js.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.js +132 -0
- package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
- package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
- package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +21 -1
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +23 -0
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
- package/src/__tests__/AuthProviderEngine.test.ts +278 -0
- package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
- package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
- package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
- package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
- package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
- package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
- package/src/__tests__/RSUProgressBridge.test.ts +250 -0
- package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
- package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
- package/src/__tests__/SessionManager.test.ts +55 -0
- package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
- package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
- package/src/__tests__/config-units.test.ts +74 -0
- package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
- package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
- package/src/__tests__/newUsers.test.ts +726 -0
- package/src/__tests__/uploadTokenManager.test.ts +159 -0
- package/src/agentSessions/SessionManager.ts +27 -9
- package/src/auth/AuthProviderCatalogRouter.ts +76 -0
- package/src/auth/AuthProviderEngine.ts +253 -0
- package/src/auth/index.ts +60 -15
- package/src/auth/initializeProviders.ts +128 -7
- package/src/auth/scopeLimitedPrincipal.ts +50 -0
- package/src/config.ts +55 -36
- package/src/context.ts +26 -2
- package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
- package/src/generated/generated.ts +2347 -10551
- package/src/generic/ResolverBase.ts +67 -8
- package/src/generic/RunViewResolver.ts +31 -0
- package/src/index.ts +165 -16
- package/src/integration/CustomColumnPromoter.ts +520 -98
- package/src/integration/InactiveRowWarnings.ts +85 -0
- package/src/integration/RSUProgressBridge.ts +248 -0
- package/src/integration/SchemaRefreshLaunch.ts +93 -0
- package/src/providerConfigUnits.ts +21 -0
- package/src/resolvers/AdhocQueryResolver.ts +21 -6
- package/src/resolvers/FileResolver.ts +200 -40
- package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +390 -71
- package/src/resolvers/QueryResolver.ts +25 -11
- package/src/resolvers/QuerySystemUserResolver.ts +28 -13
- package/src/resolvers/RunAIAgentResolver.ts +21 -6
- package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
- package/src/resolvers/UserResolver.ts +99 -60
- package/src/resolvers/currentUserRoles.ts +47 -0
- package/src/resolvers/meetingRecordingRegistration.ts +3 -7
- package/src/rest/MediaAccessKeys.ts +47 -1
- package/src/rest/MediaStreamHandler.ts +98 -3
- package/src/rest/OAuthCallbackHandler.ts +149 -41
- package/src/rest/UploadTokenManager.ts +211 -0
- package/src/services/IntegrationSyncWorkerService.ts +141 -0
- package/src/services/StartTaskGraphDispatcher.ts +21 -1
- package/src/services/TaskGraphAgentRunner.ts +23 -0
- package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
- 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
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
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
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
272
|
-
const work = this.resolveWorkItems(passing, entityInfo,
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
/**
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
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.
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
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
|
-
|
|
583
|
-
|
|
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:
|
|
591
|
-
StartRow:
|
|
954
|
+
MaxRows: OVERFLOW_PAGE_SIZE,
|
|
955
|
+
StartRow: seen - removed,
|
|
592
956
|
}, this.user);
|
|
593
957
|
if (!res.Success) {
|
|
594
|
-
LogError(`[CustomColumnPromoter]
|
|
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
|
-
|
|
963
|
+
if (stop?.()) return;
|
|
964
|
+
if (await visit(row)) removed++;
|
|
965
|
+
seen++;
|
|
600
966
|
}
|
|
601
|
-
if (rows.length <
|
|
602
|
-
|
|
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
|
-
/**
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|