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