@memberjunction/server 5.41.0 → 5.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/agentSessions/index.d.ts +1 -0
  2. package/dist/agentSessions/index.d.ts.map +1 -1
  3. package/dist/agentSessions/index.js +5 -0
  4. package/dist/agentSessions/index.js.map +1 -1
  5. package/dist/agentSessions/remoteBrowserGoalEngine.d.ts +102 -0
  6. package/dist/agentSessions/remoteBrowserGoalEngine.d.ts.map +1 -0
  7. package/dist/agentSessions/remoteBrowserGoalEngine.js +164 -0
  8. package/dist/agentSessions/remoteBrowserGoalEngine.js.map +1 -0
  9. package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts +66 -0
  10. package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts.map +1 -0
  11. package/dist/agentSessions/remoteBrowserGoalRegistry.js +86 -0
  12. package/dist/agentSessions/remoteBrowserGoalRegistry.js.map +1 -0
  13. package/dist/auth/initializeProviders.d.ts.map +1 -1
  14. package/dist/auth/initializeProviders.js +4 -7
  15. package/dist/auth/initializeProviders.js.map +1 -1
  16. package/dist/config.d.ts +7 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +12 -2
  19. package/dist/config.js.map +1 -1
  20. package/dist/generated/generated.d.ts +591 -0
  21. package/dist/generated/generated.d.ts.map +1 -1
  22. package/dist/generated/generated.js +6832 -3610
  23. package/dist/generated/generated.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +33 -16
  26. package/dist/index.js.map +1 -1
  27. package/dist/integration/CustomColumnPromoter.d.ts +108 -0
  28. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -0
  29. package/dist/integration/CustomColumnPromoter.js +508 -0
  30. package/dist/integration/CustomColumnPromoter.js.map +1 -0
  31. package/dist/logging/StartupLogger.d.ts +57 -1
  32. package/dist/logging/StartupLogger.d.ts.map +1 -1
  33. package/dist/logging/StartupLogger.js +115 -6
  34. package/dist/logging/StartupLogger.js.map +1 -1
  35. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +27 -0
  36. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -0
  37. package/dist/resolvers/ExecuteRemoteOperationResolver.js +136 -0
  38. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -0
  39. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +101 -1
  40. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  41. package/dist/resolvers/IntegrationDiscoveryResolver.js +544 -42
  42. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  43. package/dist/resolvers/RealtimeBridgeResolver.d.ts +121 -0
  44. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -0
  45. package/dist/resolvers/RealtimeBridgeResolver.js +533 -0
  46. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -0
  47. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +78 -5
  48. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  49. package/dist/resolvers/RemoteBrowserActionResolver.js +227 -15
  50. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  51. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  52. package/dist/resolvers/RunAIAgentResolver.js +0 -7
  53. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  54. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  55. package/dist/rest/setupRESTEndpoints.js +5 -3
  56. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  57. package/package.json +83 -78
  58. package/src/__tests__/RealtimeBridgeResolver.test.ts +100 -0
  59. package/src/__tests__/RemoteBrowserAudioStream.test.ts +8 -1
  60. package/src/__tests__/RemoteBrowserGoalEngine.test.ts +144 -0
  61. package/src/__tests__/remoteBrowserGoalRegistry.test.ts +81 -0
  62. package/src/agentSessions/index.ts +5 -0
  63. package/src/agentSessions/remoteBrowserGoalEngine.ts +191 -0
  64. package/src/agentSessions/remoteBrowserGoalRegistry.ts +122 -0
  65. package/src/auth/initializeProviders.ts +4 -7
  66. package/src/config.ts +13 -2
  67. package/src/generated/generated.ts +2444 -205
  68. package/src/index.ts +33 -16
  69. package/src/integration/CustomColumnPromoter.ts +597 -0
  70. package/src/logging/StartupLogger.ts +130 -6
  71. package/src/resolvers/ExecuteRemoteOperationResolver.ts +108 -0
  72. package/src/resolvers/IntegrationDiscoveryResolver.ts +396 -37
  73. package/src/resolvers/RealtimeBridgeResolver.ts +450 -0
  74. package/src/resolvers/RemoteBrowserActionResolver.ts +232 -17
  75. package/src/resolvers/RunAIAgentResolver.ts +1 -9
  76. package/src/rest/setupRESTEndpoints.ts +5 -3
@@ -0,0 +1,597 @@
1
+ /**
2
+ * Post-sync custom-column promoter (gaps.md §2 / M2b-server).
3
+ *
4
+ * Registered onto {@link IntegrationEngine} as the post-sync schema-promotion hook (the engine
5
+ * itself has no dependency on RSU/CodeGen — see SetPostSyncSchemaPromotionCallback). After a
6
+ * sync drains, the engine invokes this for the synced entities; here we:
7
+ *
8
+ * 1. GATE — sample the overflow column per entity. No overflow rows → no work (this is what
9
+ * keeps a customs-free sync single-stage / 1×; your hard rule).
10
+ * 2. PLAN — {@link buildOverflowStats} + {@link planPromotions} decide which keys earn a real
11
+ * column and the generously-bounded type for each (pure engine logic, unit-tested).
12
+ * 3. PROMOTE — for the winners: ADD COLUMN via {@link DDLGenerator} + {@link RuntimeSchemaManager}
13
+ * (CodeGen reflects the column into a real EntityField), an IOF row per field (IsCustom,
14
+ * MetadataSource='Discovered'), and a field map so the next sync maps it natively → the
15
+ * capture/promote loop terminates.
16
+ *
17
+ * DIALECT PARITY (crucial, per PR #2752): the coverage scan is RunView (dialect-agnostic); DDL
18
+ * goes through DDLGenerator driven by provider.PlatformKey ('sqlserver' | 'postgresql'); RSU has
19
+ * dual SS/PG setup; IOF + field map are BaseEntity. No SS-only SQL anywhere in this file.
20
+ *
21
+ * The MJAPI restart (so the new column is exposed over GraphQL) + the JSON spread of staged
22
+ * values into the new columns are M3 — this stage applies the schema only (SkipRestart).
23
+ */
24
+ import {
25
+ LogError,
26
+ LogStatus,
27
+ LogStatusEx,
28
+ Metadata,
29
+ RunView,
30
+ type UserInfo,
31
+ type IMetadataProvider,
32
+ type DatabaseProviderBase,
33
+ type EntityInfo,
34
+ } from '@memberjunction/core';
35
+ import {
36
+ IntegrationEngine,
37
+ CUSTOM_OVERFLOW_COLUMN,
38
+ CONTENT_HASH_COLUMN,
39
+ computeContentHash,
40
+ buildOverflowStats,
41
+ planPromotions,
42
+ sanitizeColumnName,
43
+ type SchemaPromotionResult,
44
+ type PromotionCandidate,
45
+ } from '@memberjunction/integration-engine';
46
+ import type { BaseEntity } from '@memberjunction/core';
47
+ import { DDLGenerator, type TargetColumnConfig, type DatabasePlatform } from '@memberjunction/integration-schema-builder';
48
+ import { RuntimeSchemaManager, type RSUPipelineInput } from '@memberjunction/schema-engine';
49
+ import {
50
+ MJCompanyIntegrationEntity,
51
+ MJCompanyIntegrationEntityMapEntity,
52
+ MJCompanyIntegrationFieldMapEntity,
53
+ MJIntegrationObjectEntity,
54
+ MJIntegrationObjectFieldEntity,
55
+ } from '@memberjunction/core-entities';
56
+
57
+ /** Max rows sampled from the overflow column to compute coverage + infer types (bounded memory). */
58
+ const OVERFLOW_SAMPLE_SIZE = 1000;
59
+
60
+ /**
61
+ * Max columns promoted in a SINGLE pass, so a feed that suddenly exposes hundreds of distinct
62
+ * high-coverage keys can't mint hundreds of columns in one sync (bounded schema churn). The
63
+ * remainder is logged + deferred; it promotes on subsequent syncs (the keys stay captured).
64
+ */
65
+ const MAX_PROMOTIONS_PER_PASS = 25;
66
+
67
+ /** A coverage-passing key with its resolved column + what work it still needs (M4: promote OR recover). */
68
+ interface WorkItem {
69
+ candidate: PromotionCandidate;
70
+ /** Original source key — the field map's SourceFieldName + the IOF Name. */
71
+ sourceKey: string;
72
+ /** Sanitized, collision-resolved column / DestinationFieldName / EntityField name. */
73
+ columnName: string;
74
+ /** The real DB column does not exist yet → needs ADD COLUMN. */
75
+ needsColumn: boolean;
76
+ /** No active field map for this source key → needs one (covers the partial-promotion crash window). */
77
+ needsFieldMap: boolean;
78
+ }
79
+
80
+ /** Registers the post-sync custom-column promotion hook on the IntegrationEngine singleton. */
81
+ export function registerIntegrationCustomColumnPromoter(): void {
82
+ IntegrationEngine.Instance.SetPostSyncSchemaPromotionCallback(async (ctx) => {
83
+ const user = ctx.ContextUser as UserInfo;
84
+ const provider = ctx.Provider as IMetadataProvider | undefined;
85
+ // GATE — promotion runs RSU (ADD COLUMN + register EntityField) + restart, which is disruptive.
86
+ // It is OPT-IN per connection (DEFAULT OFF): by default a sync only CAPTURES unmapped fields into
87
+ // the overflow column; the user triggers promotion on demand via IntegrationPromoteCustomColumns
88
+ // (after reviewing IntegrationListCustomColumnCandidates). Set Configuration.autoPromoteCustomColumns=true
89
+ // to restore automatic post-sync promotion. (Capture is unconditional; only the RSU step is gated.)
90
+ if (!(await readAutoPromoteFlag(ctx.CompanyIntegrationID, user, provider))) {
91
+ LogStatus(
92
+ `[CustomColumnPromoter] Auto-promote OFF for CI ${ctx.CompanyIntegrationID} — unmapped fields captured to ` +
93
+ `the overflow column; awaiting on-demand promotion (IntegrationPromoteCustomColumns).`
94
+ );
95
+ return { Promoted: false, ColumnsAdded: [], SchemaUpdatePending: false };
96
+ }
97
+ const promoter = new IntegrationCustomColumnPromoter(user, provider);
98
+ return promoter.PromoteForSync(ctx.CompanyIntegrationID, ctx.SyncedEntityNames);
99
+ });
100
+ // Verbose-only: this is a boot-time registration confirmation, not operator-actionable at
101
+ // standard level. Routes through the global verbose gate (set from the server's telemetry.level).
102
+ LogStatusEx({ message: '[CustomColumnPromoter] Registered post-sync custom-column promotion hook (auto-promote opt-in, default OFF).', verboseOnly: true });
103
+ }
104
+
105
+ /** Reads the per-connection `autoPromoteCustomColumns` flag (default false = capture-only, on-demand promotion). */
106
+ async function readAutoPromoteFlag(companyIntegrationID: string, user: UserInfo, provider?: IMetadataProvider): Promise<boolean> {
107
+ try {
108
+ const md = provider ?? Metadata.Provider;
109
+ const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
110
+ if (!(await ci.Load(companyIntegrationID)) || !ci.Configuration) return false;
111
+ return (JSON.parse(ci.Configuration) as { autoPromoteCustomColumns?: boolean }).autoPromoteCustomColumns === true;
112
+ } catch { return false; }
113
+ }
114
+
115
+ const NOT_PROMOTED: SchemaPromotionResult = { Promoted: false, ColumnsAdded: [], SchemaUpdatePending: false };
116
+
117
+ /**
118
+ * Orchestrates promotion for one sync run. One instance per invocation (carries the per-sync
119
+ * context user + provider so it respects the bound provider, never the global default blindly).
120
+ */
121
+ export class IntegrationCustomColumnPromoter {
122
+ constructor(
123
+ private readonly user: UserInfo,
124
+ private readonly providerOverride?: IMetadataProvider,
125
+ ) {}
126
+
127
+ private get provider(): IMetadataProvider {
128
+ return this.providerOverride ?? Metadata.Provider;
129
+ }
130
+
131
+ private get dbProvider(): DatabaseProviderBase {
132
+ return this.provider as unknown as DatabaseProviderBase;
133
+ }
134
+
135
+ /** Entry point: promote custom columns for every entity touched by the sync. */
136
+ public async PromoteForSync(
137
+ companyIntegrationID: string,
138
+ syncedEntityNames: string[],
139
+ ): Promise<SchemaPromotionResult> {
140
+ const integrationID = await this.resolveIntegrationID(companyIntegrationID);
141
+ if (!integrationID) return NOT_PROMOTED;
142
+
143
+ const columnsAdded: Array<{ EntityName: string; ColumnName: string }> = [];
144
+ for (const entityName of syncedEntityNames) {
145
+ try {
146
+ const added = await this.promoteEntity(companyIntegrationID, integrationID, entityName);
147
+ columnsAdded.push(...added);
148
+ } catch (err) {
149
+ // One entity's promotion failure must not abort the others, and never the sync.
150
+ LogError(`[CustomColumnPromoter] Promotion failed for entity '${entityName}': ${this.msg(err)}`);
151
+ }
152
+ }
153
+
154
+ const promoted = columnsAdded.length > 0;
155
+ if (promoted) {
156
+ // Make the freshly-created EntityFields visible in-process for the next sync's mapping.
157
+ try { await this.provider.Refresh(); } catch (err) { LogError(`[CustomColumnPromoter] provider.Refresh failed: ${this.msg(err)}`); }
158
+ }
159
+ return { Promoted: promoted, ColumnsAdded: columnsAdded, SchemaUpdatePending: promoted };
160
+ }
161
+
162
+ /** Gate → plan → promote for a single target entity. Returns the columns it added. */
163
+ private async promoteEntity(
164
+ companyIntegrationID: string,
165
+ integrationID: string,
166
+ entityName: string,
167
+ ): Promise<Array<{ EntityName: string; ColumnName: string }>> {
168
+ const planned = await this.planWorkForEntity(companyIntegrationID, entityName);
169
+ if (!planned || planned.work.length === 0) return []; // no overflow / no entity map / already converged
170
+ const { entityInfo, entityMap } = planned;
171
+ let work = planned.work;
172
+
173
+ // M4a: bound schema churn per pass — the remainder stays captured and promotes next sync.
174
+ if (work.length > MAX_PROMOTIONS_PER_PASS) {
175
+ LogStatus(`[CustomColumnPromoter] ${work.length} candidates on ${entityName}; promoting ${MAX_PROMOTIONS_PER_PASS} this pass, ${work.length - MAX_PROMOTIONS_PER_PASS} deferred to next sync.`);
176
+ work = work.slice(0, MAX_PROMOTIONS_PER_PASS);
177
+ }
178
+
179
+ // 4. PROMOTE: ADD COLUMN+CodeGen (only the keys that need a column) → IOF rows → field maps → spread.
180
+ const newColumns = work.filter(w => w.needsColumn);
181
+ if (newColumns.length > 0 && !await this.applySchemaChange(entityInfo, newColumns)) {
182
+ return []; // DDL failed — leave everything captured; retry next sync (no partial commit lost)
183
+ }
184
+ await this.createIntegrationObjectFields(integrationID, entityMap.ExternalObjectName, work);
185
+ await this.createFieldMaps(entityMap.ID, work.filter(w => w.needsFieldMap));
186
+ // M3: spread staged values into the real columns + re-baseline the content hash.
187
+ // CRITICAL: refresh metadata FIRST. applySchemaChange just added the columns AND had RSU
188
+ // regenerate the spUpdate sproc to include them — but the running provider's in-memory entity
189
+ // metadata still predates the column add (the Refresh at the top ran before it). Without this,
190
+ // the spread's row.Save() builds a sproc call from the STALE field list that doesn't match the
191
+ // regenerated sproc → "Error executing SQL" (the spread-save failures). Re-loading metadata
192
+ // here aligns the entity's field set with the new DB sproc so the backfill saves succeed.
193
+ try { await this.provider.Refresh(); } catch (err) { LogError(`[CustomColumnPromoter] pre-spread Refresh failed: ${this.msg(err)}`); }
194
+ const refreshedEntityInfo = this.provider.EntityByName(entityName) ?? entityInfo;
195
+ await this.spreadAndRebaseline(entityName, entityMap.ID, refreshedEntityInfo, work);
196
+
197
+ LogStatus(`[CustomColumnPromoter] Promoted/recovered ${work.length} custom column(s) on ${entityName}: ${work.map(w => w.columnName).join(', ')}`);
198
+ return work.map(w => ({ EntityName: entityName, ColumnName: w.columnName }));
199
+ }
200
+
201
+ /**
202
+ * Dry-run of GATE → scan → PLAN → resolve-work for ONE entity, WITHOUT applying any schema change.
203
+ * Shared by {@link promoteEntity} (which then PROMOTES the work) and {@link ListCandidates} (which only
204
+ * reports it). Returns null when the entity has no overflow column / no captured customs / no entity map.
205
+ * Because the work list is computed live (overflow keys minus already-column-and-mapped), re-running is
206
+ * inherently deduped — a concurrent discovery that already promoted a key yields no work item for it.
207
+ */
208
+ private async planWorkForEntity(
209
+ companyIntegrationID: string,
210
+ entityName: string,
211
+ ): Promise<{ entityInfo: EntityInfo; entityMap: { ID: string; ExternalObjectName: string }; work: WorkItem[] } | null> {
212
+ const entityInfo = this.provider.EntityByName(entityName);
213
+ if (!entityInfo?.SchemaName || !entityInfo.BaseTable) return null;
214
+ // No overflow column on this table (predates the feature) → nothing to promote.
215
+ if (!entityInfo.Fields.some(f => f.Name === CUSTOM_OVERFLOW_COLUMN)) return null;
216
+
217
+ const overflowJson = await this.scanOverflow(entityName);
218
+ if (overflowJson.length === 0) return null; // no customs captured
219
+
220
+ const passing = planPromotions(buildOverflowStats(overflowJson), {});
221
+ if (passing.length === 0) return null;
222
+
223
+ const entityMap = await this.findEntityMap(companyIntegrationID, entityName);
224
+ if (!entityMap) {
225
+ LogError(`[CustomColumnPromoter] No entity map for ${entityName} on CI ${companyIntegrationID}.`);
226
+ return null;
227
+ }
228
+
229
+ // Skip fully-terminated keys (column + field map both exist); keep promote (needs column) / recover.
230
+ const fieldMapSources = await this.activeFieldMapSources(entityMap.ID);
231
+ const work = this.resolveWorkItems(passing, entityInfo, fieldMapSources);
232
+ return { entityInfo, entityMap, work };
233
+ }
234
+
235
+ /**
236
+ * Lists the custom-column CANDIDATES for one entity — the "new columns found" awaiting promotion,
237
+ * computed live from the overflow column minus already-mapped/already-a-column keys (inherently deduped).
238
+ * READ-ONLY: no schema change, no RSU. Backs `IntegrationListCustomColumnCandidates`.
239
+ */
240
+ public async ListCandidates(
241
+ companyIntegrationID: string,
242
+ entityName: string,
243
+ ): Promise<Array<{ EntityName: string; SourceKey: string; ColumnName: string; InferredType: string; NeedsColumn: boolean }>> {
244
+ const planned = await this.planWorkForEntity(companyIntegrationID, entityName);
245
+ if (!planned) return [];
246
+ return planned.work.map(w => ({
247
+ EntityName: entityName,
248
+ SourceKey: w.sourceKey,
249
+ ColumnName: w.columnName,
250
+ InferredType: w.candidate.Inferred.SchemaFieldType,
251
+ NeedsColumn: w.needsColumn,
252
+ }));
253
+ }
254
+
255
+ /** Samples the overflow column (rows where it is non-null) — dialect-agnostic via RunView. */
256
+ private async scanOverflow(entityName: string): Promise<Array<string | null>> {
257
+ const rv = new RunView();
258
+ const res = await rv.RunView<Record<string, unknown>>({
259
+ EntityName: entityName,
260
+ Fields: [CUSTOM_OVERFLOW_COLUMN],
261
+ ExtraFilter: `${CUSTOM_OVERFLOW_COLUMN} IS NOT NULL`,
262
+ MaxRows: OVERFLOW_SAMPLE_SIZE,
263
+ ResultType: 'simple',
264
+ }, this.user);
265
+ if (!res.Success) {
266
+ LogError(`[CustomColumnPromoter] Overflow scan failed for ${entityName}: ${res.ErrorMessage}`);
267
+ return [];
268
+ }
269
+ return (res.Results ?? []).map(r => (r[CUSTOM_OVERFLOW_COLUMN] as string | null) ?? null);
270
+ }
271
+
272
+ /**
273
+ * Splits coverage-passing keys into actionable work items, dropping any that are fully
274
+ * terminated (column AND field map both exist). A key whose column exists but whose field
275
+ * map is missing becomes a RECOVERY item (covers the crash window between ADD COLUMN and the
276
+ * field-map write) — needsColumn=false, needsFieldMap=true.
277
+ */
278
+ private resolveWorkItems(
279
+ passing: PromotionCandidate[],
280
+ entityInfo: EntityInfo,
281
+ fieldMapSources: ReadonlySet<string>,
282
+ ): WorkItem[] {
283
+ const existingByLower = new Map(entityInfo.Fields.map(f => [f.Name.toLowerCase(), f.Name]));
284
+ const taken = new Set(entityInfo.Fields.map(f => f.Name.toLowerCase()));
285
+ const items: WorkItem[] = [];
286
+ for (const candidate of passing) {
287
+ const existingCol = existingByLower.get(sanitizeColumnName(candidate.Key).toLowerCase());
288
+ const hasColumn = !!existingCol;
289
+ const hasFieldMap = fieldMapSources.has(candidate.Key.toLowerCase());
290
+ if (hasColumn && hasFieldMap) continue; // terminated — nothing to do
291
+ const columnName = existingCol ?? this.uniqueColumnName(sanitizeColumnName(candidate.Key), taken);
292
+ if (!hasColumn) taken.add(columnName.toLowerCase());
293
+ items.push({ candidate, sourceKey: candidate.Key, columnName, needsColumn: !hasColumn, needsFieldMap: !hasFieldMap });
294
+ }
295
+ return items;
296
+ }
297
+
298
+ /** Active field-map SOURCE field names for an entity map (lowercased) — for the terminate/recovery check. */
299
+ private async activeFieldMapSources(entityMapID: string): Promise<ReadonlySet<string>> {
300
+ const rv = new RunView();
301
+ const res = await rv.RunView<MJCompanyIntegrationFieldMapEntity>({
302
+ EntityName: 'MJ: Company Integration Field Maps',
303
+ ExtraFilter: `EntityMapID='${entityMapID}' AND Status='Active'`,
304
+ Fields: ['SourceFieldName'],
305
+ ResultType: 'simple',
306
+ }, this.user);
307
+ return new Set(res.Success ? (res.Results ?? []).map(r => (r.SourceFieldName ?? '').toLowerCase()) : []);
308
+ }
309
+
310
+ /** Suffixes _2, _3, … until the sanitized name does not collide with an existing/assigned one. */
311
+ private uniqueColumnName(base: string, taken: Set<string>): string {
312
+ if (!taken.has(base.toLowerCase())) return base;
313
+ for (let i = 2; i < 1000; i++) {
314
+ const candidate = `${base}_${i}`;
315
+ if (!taken.has(candidate.toLowerCase())) return candidate;
316
+ }
317
+ return `${base}_${Date.now() % 100000}`; // pathological fallback (never expected)
318
+ }
319
+
320
+ /** Generates ADD COLUMN DDL for the new columns and runs it through RSU (CodeGen reflects them). */
321
+ private async applySchemaChange(entityInfo: EntityInfo, named: WorkItem[]): Promise<boolean> {
322
+ const platform = this.dbProvider.PlatformKey as DatabasePlatform;
323
+ const ddl = new DDLGenerator();
324
+ const statements = named.map(n =>
325
+ ddl.GenerateAlterTableAddColumn(
326
+ entityInfo.SchemaName,
327
+ entityInfo.BaseTable,
328
+ this.toTargetColumn(n, platform),
329
+ platform,
330
+ ),
331
+ );
332
+ const input: RSUPipelineInput = {
333
+ MigrationSQL: statements.join('\n'),
334
+ Description: `Promote ${named.length} custom column(s) on ${entityInfo.Name}`,
335
+ AffectedTables: [`${entityInfo.SchemaName}.${entityInfo.BaseTable}`],
336
+ // M3 owns the restart-signal + restart; this stage applies schema only. Runtime
337
+ // promotion creates no git commit (the dev RSU flow does; a per-sync commit is noise).
338
+ SkipRestart: true,
339
+ SkipGitCommit: true,
340
+ };
341
+ const result = await RuntimeSchemaManager.Instance.RunPipeline(input);
342
+ if (!result.Success) {
343
+ LogError(`[CustomColumnPromoter] RSU ADD COLUMN failed on ${entityInfo.Name}: ${result.ErrorMessage ?? result.ErrorStep ?? 'unknown'}`);
344
+ }
345
+ return result.Success;
346
+ }
347
+
348
+ /** Builds a per-platform TargetColumnConfig from a planned candidate. */
349
+ private toTargetColumn(n: WorkItem, platform: DatabasePlatform): TargetColumnConfig {
350
+ const inferred = n.candidate.Inferred;
351
+ return {
352
+ SourceFieldName: n.sourceKey,
353
+ TargetColumnName: n.columnName,
354
+ TargetSqlType: platform === 'sqlserver' ? inferred.SqlServerType : inferred.PostgresType,
355
+ IsNullable: true, // customs are always nullable (never fabricate NOT NULL)
356
+ MaxLength: inferred.MaxLength,
357
+ Precision: null,
358
+ Scale: null,
359
+ DefaultValue: null,
360
+ };
361
+ }
362
+
363
+ /** Creates an IOF row per promoted field (IsCustom, MetadataSource='Discovered'). */
364
+ private async createIntegrationObjectFields(
365
+ integrationID: string,
366
+ externalObjectName: string,
367
+ named: WorkItem[],
368
+ ): Promise<void> {
369
+ const objectID = await this.resolveIntegrationObjectID(integrationID, externalObjectName);
370
+ if (!objectID) {
371
+ LogError(`[CustomColumnPromoter] No IntegrationObject '${externalObjectName}' for integration ${integrationID}; IOF rows skipped.`);
372
+ return;
373
+ }
374
+ // Lookup-or-reactivate-or-create — NEVER blind-create over an existing field. A field the source
375
+ // dropped is DEACTIVATED (Status='Inactive'), not deleted, and its column is preserved. When that
376
+ // key reappears in the payload and reaches promotion, the IOF already exists: reactivate it (so the
377
+ // active-filtered ApplyAll re-materializes the still-present column) instead of creating a duplicate.
378
+ // This is the "removed-then-re-added" case + the recovery case (column existed, field map missing).
379
+ const existingIOFs = await this.existingIOFsByName(objectID);
380
+ for (const n of named) {
381
+ const existing = existingIOFs.get(n.sourceKey.toLowerCase());
382
+ if (existing) {
383
+ if (existing.Status !== 'Active') {
384
+ const reIof = await this.provider.GetEntityObject<MJIntegrationObjectFieldEntity>('MJ: Integration Object Fields', this.user);
385
+ if (await reIof.Load(existing.ID)) {
386
+ reIof.Status = 'Active';
387
+ const ok = await reIof.Save();
388
+ if (!ok) LogError(`[CustomColumnPromoter] Failed to reactivate IOF '${n.sourceKey}': ${reIof.LatestResult?.CompleteMessage ?? 'unknown'}`);
389
+ }
390
+ }
391
+ continue;
392
+ }
393
+ const iof = await this.provider.GetEntityObject<MJIntegrationObjectFieldEntity>('MJ: Integration Object Fields', this.user);
394
+ iof.NewRecord();
395
+ iof.IntegrationObjectID = objectID;
396
+ iof.Name = n.sourceKey;
397
+ iof.DisplayName = n.sourceKey;
398
+ iof.Description = `Custom field discovered during sync; promoted to column ${n.columnName}.`;
399
+ iof.Type = n.candidate.Inferred.SchemaFieldType;
400
+ iof.Length = n.candidate.Inferred.MaxLength;
401
+ iof.AllowsNull = true;
402
+ iof.IsPrimaryKey = false; // never fabricated for customs (deferred to D4)
403
+ iof.IsUniqueKey = false;
404
+ iof.IsReadOnly = false;
405
+ iof.IsRequired = false;
406
+ iof.IsCustom = true;
407
+ // Provenance is STAMPED by this write path, never inferred later. 'Discovered' = the
408
+ // system found it automatically (vs 'Declared' curated / 'Custom' customer-added). The
409
+ // Configuration marker records the finer truth: it was data-sampled from a flat-file
410
+ // feed (not an authoritative describe endpoint) with this coverage, and its type was
411
+ // INFERRED — so consumers can treat it as soft / safely re-typable.
412
+ iof.MetadataSource = 'Discovered';
413
+ iof.Configuration = JSON.stringify({
414
+ promotedFrom: 'overflow',
415
+ coverage: Number(n.candidate.Coverage.toFixed(3)),
416
+ typeInferredFromData: true,
417
+ });
418
+ iof.Status = 'Active';
419
+ if (!await iof.Save()) {
420
+ LogError(`[CustomColumnPromoter] IOF save failed for ${n.sourceKey}: ${iof.LatestResult?.CompleteMessage ?? 'unknown'}`);
421
+ }
422
+ }
423
+ }
424
+
425
+ /** Creates a field map (source key → new column) so the next sync maps natively → terminate. */
426
+ private async createFieldMaps(entityMapID: string, named: WorkItem[]): Promise<void> {
427
+ for (const n of named) {
428
+ const fm = await this.provider.GetEntityObject<MJCompanyIntegrationFieldMapEntity>('MJ: Company Integration Field Maps', this.user);
429
+ fm.NewRecord();
430
+ fm.EntityMapID = entityMapID;
431
+ fm.SourceFieldName = n.sourceKey;
432
+ fm.DestinationFieldName = n.columnName;
433
+ fm.IsKeyField = false;
434
+ fm.IsRequired = false;
435
+ fm.Status = 'Active';
436
+ if (!await fm.Save()) {
437
+ LogError(`[CustomColumnPromoter] Field map save failed for ${n.sourceKey}: ${fm.LatestResult?.CompleteMessage ?? 'unknown'}`);
438
+ }
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Spreads the staged overflow JSON values into the freshly-created real columns, then
444
+ * re-baselines the content hash (gaps.md §2 step 3). JS per-row pass — dialect-agnostic (no
445
+ * cast SQL, which is where PG bugs hide); BaseEntity handles the dialect on write. The overflow
446
+ * column is intentionally NOT cleared: once the field map exists the key is no longer "unmapped",
447
+ * so the next sync stops re-capturing it and planPromotions skips the now-existing column — the
448
+ * stale value self-heals. Bounded to rows that carry overflow, paged, once on the discovery sync.
449
+ */
450
+ private async spreadAndRebaseline(
451
+ entityName: string,
452
+ entityMapID: string,
453
+ entityInfo: EntityInfo,
454
+ named: WorkItem[],
455
+ ): Promise<void> {
456
+ const hasHashCol = entityInfo.Fields.some(f => f.Name === CONTENT_HASH_COLUMN);
457
+ const mappedDestFields = hasHashCol ? await this.activeDestinationFields(entityMapID) : [];
458
+
459
+ let startRow = 0;
460
+ const pageSize = 500;
461
+ for (;;) {
462
+ const rv = new RunView();
463
+ const res = await rv.RunView<BaseEntity>({
464
+ EntityName: entityName,
465
+ ExtraFilter: `${CUSTOM_OVERFLOW_COLUMN} IS NOT NULL`,
466
+ ResultType: 'entity_object',
467
+ MaxRows: pageSize,
468
+ StartRow: startRow,
469
+ }, this.user);
470
+ if (!res.Success) {
471
+ LogError(`[CustomColumnPromoter] Spread scan failed for ${entityName}: ${res.ErrorMessage}`);
472
+ return;
473
+ }
474
+ const rows = res.Results ?? [];
475
+ for (const row of rows) {
476
+ await this.spreadOneRow(row, named, hasHashCol, mappedDestFields);
477
+ }
478
+ if (rows.length < pageSize) break;
479
+ startRow += pageSize;
480
+ }
481
+ }
482
+
483
+ /** Applies the staged values + re-baselined hash to a single row entity and saves it. */
484
+ private async spreadOneRow(
485
+ row: BaseEntity,
486
+ named: WorkItem[],
487
+ hasHashCol: boolean,
488
+ mappedDestFields: string[],
489
+ ): Promise<void> {
490
+ // Dynamic .Get/.Set is REQUIRED here: these columns were created at runtime and have no
491
+ // generated typed property in this still-running process (full typed access arrives on the
492
+ // post-promotion restart). This is the sanctioned exception to the no-.Get/.Set rule.
493
+ const overflow = this.parseOverflow(row.Get(CUSTOM_OVERFLOW_COLUMN));
494
+ if (!overflow) return;
495
+ let changed = false;
496
+ for (const n of named) {
497
+ if (Object.prototype.hasOwnProperty.call(overflow, n.sourceKey)) {
498
+ row.Set(n.columnName, overflow[n.sourceKey]);
499
+ changed = true;
500
+ }
501
+ }
502
+ if (!changed) return;
503
+ if (hasHashCol) {
504
+ // Re-baseline to the next-sync value: hash over all active mapped destination columns
505
+ // (now incl. the new ones) as they sit on the row — matches what the next sync computes.
506
+ const mapped: Record<string, unknown> = {};
507
+ for (const dest of mappedDestFields) mapped[dest] = row.Get(dest);
508
+ row.Set(CONTENT_HASH_COLUMN, computeContentHash(mapped));
509
+ }
510
+ if (!await row.Save()) {
511
+ LogError(`[CustomColumnPromoter] Spread save failed: ${row.LatestResult?.CompleteMessage ?? 'unknown'}`);
512
+ }
513
+ }
514
+
515
+ /** Active field-map destination column names for an entity map (for hash re-baseline). */
516
+ private async activeDestinationFields(entityMapID: string): Promise<string[]> {
517
+ const rv = new RunView();
518
+ const res = await rv.RunView<MJCompanyIntegrationFieldMapEntity>({
519
+ EntityName: 'MJ: Company Integration Field Maps',
520
+ ExtraFilter: `EntityMapID='${entityMapID}' AND Status='Active'`,
521
+ Fields: ['DestinationFieldName'],
522
+ ResultType: 'simple',
523
+ }, this.user);
524
+ return res.Success ? (res.Results ?? []).map(r => r.DestinationFieldName).filter(Boolean) : [];
525
+ }
526
+
527
+ private parseOverflow(raw: unknown): Record<string, unknown> | null {
528
+ if (typeof raw !== 'string' || raw.length === 0) return null;
529
+ try {
530
+ const parsed: unknown = JSON.parse(raw);
531
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
532
+ } catch {
533
+ return null;
534
+ }
535
+ }
536
+
537
+ /** Loads the CompanyIntegration's IntegrationID. */
538
+ private async resolveIntegrationID(companyIntegrationID: string): Promise<string | null> {
539
+ const ci = await this.provider.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', this.user);
540
+ const loaded = await ci.Load(companyIntegrationID);
541
+ return loaded ? ci.IntegrationID : null;
542
+ }
543
+
544
+ /** Finds the entity map (ExternalObject↔Entity) for this CI + target entity. */
545
+ private async findEntityMap(
546
+ companyIntegrationID: string,
547
+ entityName: string,
548
+ ): Promise<{ ID: string; ExternalObjectName: string } | null> {
549
+ const rv = new RunView();
550
+ const res = await rv.RunView<MJCompanyIntegrationEntityMapEntity>({
551
+ EntityName: 'MJ: Company Integration Entity Maps',
552
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND Entity='${this.escape(entityName)}'`,
553
+ ResultType: 'simple',
554
+ MaxRows: 1,
555
+ }, this.user);
556
+ const row = res.Success ? res.Results?.[0] : undefined;
557
+ return row ? { ID: row.ID, ExternalObjectName: row.ExternalObjectName } : null;
558
+ }
559
+
560
+ /** Existing IOF field names (lowercased) for an object — for idempotent IOF creation. */
561
+ private async existingIOFsByName(objectID: string): Promise<ReadonlyMap<string, { ID: string; Status: string }>> {
562
+ const rv = new RunView();
563
+ const res = await rv.RunView<MJIntegrationObjectFieldEntity>({
564
+ EntityName: 'MJ: Integration Object Fields',
565
+ ExtraFilter: `IntegrationObjectID='${objectID}'`,
566
+ Fields: ['ID', 'Name', 'Status'],
567
+ ResultType: 'simple',
568
+ }, this.user);
569
+ const map = new Map<string, { ID: string; Status: string }>();
570
+ if (res.Success) for (const r of res.Results ?? []) {
571
+ map.set((r.Name ?? '').toLowerCase(), { ID: String(r.ID), Status: r.Status ?? 'Active' });
572
+ }
573
+ return map;
574
+ }
575
+
576
+ /** Resolves the IntegrationObject ID for an external object name under an integration. */
577
+ private async resolveIntegrationObjectID(integrationID: string, externalObjectName: string): Promise<string | null> {
578
+ const rv = new RunView();
579
+ const res = await rv.RunView<MJIntegrationObjectEntity>({
580
+ EntityName: 'MJ: Integration Objects',
581
+ ExtraFilter: `IntegrationID='${integrationID}' AND Name='${this.escape(externalObjectName)}'`,
582
+ Fields: ['ID', 'Name', 'IntegrationID'],
583
+ ResultType: 'simple',
584
+ MaxRows: 1,
585
+ }, this.user);
586
+ const row = res.Success ? res.Results?.[0] : undefined;
587
+ return row ? row.ID : null;
588
+ }
589
+
590
+ private escape(value: string): string {
591
+ return value.replace(/'/g, "''");
592
+ }
593
+
594
+ private msg(err: unknown): string {
595
+ return err instanceof Error ? err.message : String(err);
596
+ }
597
+ }