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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/agentSessions/SessionManager.d.ts +19 -5
  2. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  3. package/dist/agentSessions/SessionManager.js +27 -9
  4. package/dist/agentSessions/SessionManager.js.map +1 -1
  5. package/dist/auth/index.d.ts +1 -0
  6. package/dist/auth/index.d.ts.map +1 -1
  7. package/dist/auth/index.js +1 -0
  8. package/dist/auth/index.js.map +1 -1
  9. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  10. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  11. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  12. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  13. package/dist/context.d.ts.map +1 -1
  14. package/dist/context.js +21 -1
  15. package/dist/context.js.map +1 -1
  16. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  17. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
  18. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  19. package/dist/generated/generated.d.ts +118 -0
  20. package/dist/generated/generated.d.ts.map +1 -1
  21. package/dist/generated/generated.js +630 -0
  22. package/dist/generated/generated.js.map +1 -1
  23. package/dist/generic/ResolverBase.d.ts +27 -0
  24. package/dist/generic/ResolverBase.d.ts.map +1 -1
  25. package/dist/generic/ResolverBase.js +50 -4
  26. package/dist/generic/ResolverBase.js.map +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +49 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/integration/CustomColumnPromoter.d.ts +79 -15
  32. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  33. package/dist/integration/CustomColumnPromoter.js +472 -97
  34. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  35. package/dist/integration/InactiveRowWarnings.d.ts +48 -0
  36. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
  37. package/dist/integration/InactiveRowWarnings.js +57 -0
  38. package/dist/integration/InactiveRowWarnings.js.map +1 -0
  39. package/dist/integration/SchemaRefreshLaunch.d.ts +10 -0
  40. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -1
  41. package/dist/integration/SchemaRefreshLaunch.js +17 -0
  42. package/dist/integration/SchemaRefreshLaunch.js.map +1 -1
  43. package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
  44. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  45. package/dist/resolvers/AdhocQueryResolver.js +20 -6
  46. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  47. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
  48. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
  49. package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
  50. package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
  51. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +35 -3
  52. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  53. package/dist/resolvers/IntegrationDiscoveryResolver.js +86 -17
  54. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  55. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  56. package/dist/resolvers/meetingRecordingRegistration.js +3 -6
  57. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  58. package/dist/types.d.ts +8 -0
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/types.js.map +1 -1
  61. package/package.json +91 -91
  62. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  63. package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
  64. package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
  65. package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
  66. package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
  67. package/src/__tests__/SessionManager.test.ts +55 -0
  68. package/src/agentSessions/SessionManager.ts +27 -9
  69. package/src/auth/index.ts +1 -0
  70. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  71. package/src/context.ts +26 -2
  72. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
  73. package/src/generated/generated.ts +437 -1
  74. package/src/generic/ResolverBase.ts +56 -4
  75. package/src/index.ts +57 -1
  76. package/src/integration/CustomColumnPromoter.ts +520 -98
  77. package/src/integration/InactiveRowWarnings.ts +85 -0
  78. package/src/integration/SchemaRefreshLaunch.ts +16 -0
  79. package/src/resolvers/AdhocQueryResolver.ts +21 -6
  80. package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
  81. package/src/resolvers/IntegrationDiscoveryResolver.ts +82 -11
  82. package/src/resolvers/meetingRecordingRegistration.ts +3 -7
  83. package/src/types.ts +8 -0
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The promoter's one-pass batch, spread recovery, and idempotent spread.
3
+ *
4
+ * Before: PromoteForSync ran the FULL RSU pipeline (migrate + CodeGen + compile) once per entity —
5
+ * a sync touching N entities with candidates paid N passes where the batch API exists to pay one.
6
+ * And a run interrupted between ADD COLUMN and the value spread was a dead end: the column and
7
+ * field map existed, so the terminate check skipped the key as done, capture stopped (the key was
8
+ * no longer unmapped), and rows kept the value only in the overflow JSON forever.
9
+ *
10
+ * These tests drive PromoteForSync with plan/metadata/spread stubbed at the seam and
11
+ * RuntimeSchemaManager mocked, plus the two pure-ish decision points (resolveWorkItems,
12
+ * spreadOneRow) directly.
13
+ */
14
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
15
+
16
+ const runPipelineBatchMock = vi.fn();
17
+ vi.mock('@memberjunction/schema-engine', () => ({
18
+ RuntimeSchemaManager: {
19
+ get Instance() {
20
+ return { RunPipelineBatch: runPipelineBatchMock };
21
+ },
22
+ },
23
+ }));
24
+
25
+ vi.mock('@memberjunction/integration-schema-builder', () => ({
26
+ DDLGenerator: class {
27
+ GenerateAlterTableAddColumn(schema: string, table: string, col: { ColumnName: string }): string {
28
+ return `ALTER TABLE [${schema}].[${table}] ADD [${col.ColumnName}]`;
29
+ }
30
+ },
31
+ }));
32
+
33
+ // The engine package registers a singleton hook at import; neutralize it.
34
+ vi.mock('@memberjunction/integration-engine', () => ({
35
+ IntegrationEngine: { Instance: { SetPostSyncSchemaPromotionCallback: vi.fn() } },
36
+ CUSTOM_OVERFLOW_COLUMN: '__mj_integration_CustomFields',
37
+ CONTENT_HASH_COLUMN: '__mj_integration_ContentHash',
38
+ computeContentHash: (mapped: Record<string, unknown>) => `hash:${Object.keys(mapped).sort().join(',')}`,
39
+ // Mirror of the real sanitizer, close enough for these fixtures (plain identifiers pass through).
40
+ sanitizeColumnName: (key: string) => key.replace(/[^A-Za-z0-9_]/g, '_'),
41
+ }));
42
+
43
+ import { IntegrationCustomColumnPromoter } from '../integration/CustomColumnPromoter.js';
44
+ import type { UserInfo, IMetadataProvider, EntityInfo } from '@memberjunction/core';
45
+
46
+ type AnyPromoter = IntegrationCustomColumnPromoter & Record<string, unknown>;
47
+
48
+ const user = { ID: 'u1' } as unknown as UserInfo;
49
+
50
+ function makeProvider() {
51
+ return {
52
+ Refresh: vi.fn().mockResolvedValue(undefined),
53
+ EntityByName: vi.fn().mockReturnValue(undefined),
54
+ PlatformKey: 'sqlserver',
55
+ } as unknown as IMetadataProvider;
56
+ }
57
+
58
+ function workItem(sourceKey: string, opts: Partial<{ needsColumn: boolean; needsFieldMap: boolean; recoverSpread: boolean }> = {}) {
59
+ return {
60
+ candidate: { Key: sourceKey, Inferred: { SchemaFieldType: 'nvarchar(100)' } },
61
+ sourceKey,
62
+ columnName: sourceKey,
63
+ needsColumn: opts.needsColumn ?? true,
64
+ needsFieldMap: opts.needsFieldMap ?? true,
65
+ recoverSpread: opts.recoverSpread ?? false,
66
+ };
67
+ }
68
+
69
+ function stubPromoter(
70
+ provider: IMetadataProvider,
71
+ planByEntity: Record<string, ReturnType<typeof workItem>[]>,
72
+ staleByEntity: Record<string, Array<{ sourceKey: string; columnName: string }>> = {},
73
+ ) {
74
+ const promoter = new IntegrationCustomColumnPromoter(user, provider) as AnyPromoter;
75
+ const p = promoter as unknown as {
76
+ resolveIntegrationID: unknown; planWorkForEntity: unknown;
77
+ createIntegrationObjectFields: unknown; createFieldMaps: unknown; spreadAndRebaseline: unknown;
78
+ buildSchemaInput: unknown; activeFieldMaps: unknown; purgeStaleOverflowKeys: unknown;
79
+ };
80
+ // CompletePromotion re-reads the active field maps (it must: post-restart it has no plan to
81
+ // trust). That is a RunView, so it is stubbed at the seam like the other DB touches.
82
+ p.activeFieldMaps = vi.fn().mockResolvedValue(new Map<string, string>());
83
+ p.resolveIntegrationID = vi.fn().mockResolvedValue('int-1');
84
+ p.planWorkForEntity = vi.fn().mockImplementation(async (_ci: string, entityName: string) => {
85
+ const work = planByEntity[entityName];
86
+ if (!work) return null;
87
+ return {
88
+ entityInfo: { Name: entityName, SchemaName: 'mjc', BaseTable: entityName } as unknown as EntityInfo,
89
+ entityMap: { ID: `em-${entityName}`, ExternalObjectName: entityName.toLowerCase() },
90
+ work,
91
+ stale: staleByEntity[entityName] ?? [],
92
+ };
93
+ });
94
+ const iof = vi.fn().mockResolvedValue(undefined);
95
+ const maps = vi.fn().mockResolvedValue(undefined);
96
+ const spread = vi.fn().mockResolvedValue(undefined);
97
+ const purge = vi.fn().mockResolvedValue(undefined);
98
+ p.createIntegrationObjectFields = iof;
99
+ p.createFieldMaps = maps;
100
+ p.spreadAndRebaseline = spread;
101
+ p.purgeStaleOverflowKeys = purge;
102
+ return { promoter: promoter as IntegrationCustomColumnPromoter, iof, maps, spread, purge };
103
+ }
104
+
105
+ beforeEach(() => {
106
+ runPipelineBatchMock.mockReset();
107
+ });
108
+
109
+ describe('PromoteForSync — one batched RSU pass', () => {
110
+ it('runs ONE RunPipelineBatch for all entities instead of one pipeline per entity', async () => {
111
+ const provider = makeProvider();
112
+ const { promoter } = stubPromoter(provider, {
113
+ A: [workItem('a1')],
114
+ B: [workItem('b1')],
115
+ C: [workItem('c1')],
116
+ });
117
+ runPipelineBatchMock.mockResolvedValue({
118
+ Results: [{ Success: true }, { Success: true }, { Success: true }],
119
+ SuccessCount: 3, FailureCount: 0, TotalCount: 3,
120
+ });
121
+
122
+ const result = await promoter.PromoteForSync('ci-1', ['A', 'B', 'C']);
123
+
124
+ expect(runPipelineBatchMock).toHaveBeenCalledTimes(1);
125
+ expect(runPipelineBatchMock.mock.calls[0][0]).toHaveLength(3);
126
+ expect(result.Promoted).toBe(true);
127
+ expect(result.ColumnsAdded).toEqual([
128
+ { EntityName: 'A', ColumnName: 'a1' },
129
+ { EntityName: 'B', ColumnName: 'b1' },
130
+ { EntityName: 'C', ColumnName: 'c1' },
131
+ ]);
132
+ // ONE metadata refresh for the whole batch — before any spread reads the field list.
133
+ expect((provider as unknown as { Refresh: ReturnType<typeof vi.fn> }).Refresh).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it('registers the follow-up as promote-columns PendingWork, and does NOT skip the restart', async () => {
137
+ // The restart is what loads the regenerated entity classes, so everything downstream of the
138
+ // DDL belongs after it. Skipping the restart to finish inline is what left promoted columns
139
+ // invisible to GraphQL; skipping the commit left the database carrying columns git had no
140
+ // record of. Both flags are optional and RSU treats absent as false, so omitting them IS
141
+ // commit + restart — no caller or platform change required.
142
+ const provider = makeProvider();
143
+ const { promoter } = stubPromoter(provider, { A: [workItem('a1')], B: [workItem('b1')] });
144
+ runPipelineBatchMock.mockResolvedValue({
145
+ Results: [{ Success: true }, { Success: true }], SuccessCount: 2, FailureCount: 0, TotalCount: 2,
146
+ });
147
+
148
+ await promoter.PromoteForSync('ci-1', ['A', 'B']);
149
+
150
+ const inputs = runPipelineBatchMock.mock.calls[0][0] as Array<Record<string, unknown>>;
151
+ for (const input of inputs) {
152
+ expect(input.SkipRestart).toBeUndefined();
153
+ expect(input.SkipGitCommit).toBeUndefined();
154
+ }
155
+ // Carried on the FIRST input: RSU restarts once for the whole batch, so one payload
156
+ // describes every entity in the pass.
157
+ const pending = (inputs[0].PendingWork as Array<Record<string, unknown>>)[0];
158
+ expect(pending.WorkType).toBe('promote-columns');
159
+ expect(pending.CompanyIntegrationID).toBe('ci-1');
160
+ expect(inputs[1].PendingWork).toBeUndefined();
161
+
162
+ // The destination names are CARRIED, not recomputed — uniqueColumnName may have suffixed
163
+ // one to dodge a collision, and re-deriving it post-restart could pick a different name.
164
+ const promoted = pending.PromotedColumns as Array<{ EntityName: string; Columns: Array<{ SourceKey: string; ColumnName: string }> }>;
165
+ expect(promoted.map(p => p.EntityName)).toEqual(['A', 'B']);
166
+ expect(promoted[0].Columns[0]).toMatchObject({ ColumnName: 'a1' });
167
+ });
168
+
169
+ it('a failed migration leaves ITS entity captured for retry and does not stop the others', async () => {
170
+ const provider = makeProvider();
171
+ const { promoter, spread } = stubPromoter(provider, {
172
+ A: [workItem('a1')],
173
+ B: [workItem('b1')],
174
+ });
175
+ runPipelineBatchMock.mockResolvedValue({
176
+ Results: [{ Success: false, ErrorMessage: 'boom' }, { Success: true }],
177
+ SuccessCount: 1, FailureCount: 1, TotalCount: 2,
178
+ });
179
+
180
+ const result = await promoter.PromoteForSync('ci-1', ['A', 'B']);
181
+
182
+ // A's DDL failed → nothing written, not counted, left captured for retry.
183
+ // B's DDL succeeded → COUNTED (SchemaUpdatePending is derived from this list, and the
184
+ // client keys its "workspace updating" state off it), but its metadata and spread are NOT
185
+ // done here: the batch restarts, so the post-restart consumer owns them. Doing them inline
186
+ // as well would duplicate the work — and for B it would not run at all, since `pm2 restart`
187
+ // ends this process.
188
+ expect(result.ColumnsAdded).toEqual([{ EntityName: 'B', ColumnName: 'b1' }]);
189
+ expect(spread).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it('purges stale staged keys BEFORE the pipeline, even with nothing to promote', async () => {
193
+ // The residue case: every key is already promoted, so there is no work and no DDL — but the
194
+ // staged JSON still carries the keys and nothing else will ever clear them. The purge must
195
+ // run anyway, and must run before any migration (it is not evidence a column is missing).
196
+ const provider = makeProvider();
197
+ const { promoter, purge } = stubPromoter(
198
+ provider,
199
+ { A: [] },
200
+ { A: [{ sourceKey: 'roundId', columnName: 'roundId' }] },
201
+ );
202
+
203
+ const result = await promoter.PromoteForSync('ci-1', ['A']);
204
+
205
+ expect(purge).toHaveBeenCalledTimes(1);
206
+ expect(purge.mock.calls[0][3]).toEqual([{ sourceKey: 'roundId', columnName: 'roundId' }]);
207
+ expect(runPipelineBatchMock).not.toHaveBeenCalled();
208
+ expect(result.Promoted).toBe(false);
209
+ });
210
+
211
+ it('a spread-recovery-only pass runs NO pipeline and reports no columns added', async () => {
212
+ const provider = makeProvider();
213
+ const { promoter, spread } = stubPromoter(provider, {
214
+ A: [workItem('a1', { needsColumn: false, needsFieldMap: false, recoverSpread: true })],
215
+ });
216
+
217
+ const result = await promoter.PromoteForSync('ci-1', ['A']);
218
+
219
+ // No DDL to run — the batch API is never touched, and SchemaUpdatePending stays honest.
220
+ expect(runPipelineBatchMock).not.toHaveBeenCalled();
221
+ expect(result.Promoted).toBe(false);
222
+ expect(result.ColumnsAdded).toEqual([]);
223
+ // But the backfill DID run — that is the whole point of the recovery item.
224
+ expect(spread).toHaveBeenCalledTimes(1);
225
+ });
226
+ });
227
+
228
+ describe('resolveWorkItems — the terminate check becomes spread recovery', () => {
229
+ /** fieldMaps: source key -> the destination column the field map names (as the data records it). */
230
+ function resolve(
231
+ promoter: IntegrationCustomColumnPromoter,
232
+ candidates: string[],
233
+ entityFields: string[],
234
+ fieldMaps: Record<string, string>,
235
+ ) {
236
+ const entityInfo = { Fields: entityFields.map(n => ({ Name: n })) } as unknown as EntityInfo;
237
+ return (promoter as unknown as {
238
+ resolveWorkItems: (p: Array<{ Key: string }>, e: EntityInfo, f: ReadonlyMap<string, string>) => Array<Record<string, unknown>>;
239
+ }).resolveWorkItems(
240
+ candidates.map(Key => ({ Key })),
241
+ entityInfo,
242
+ new Map(Object.entries(fieldMaps).map(([k, v]) => [k.toLowerCase(), v])),
243
+ );
244
+ }
245
+
246
+ it('a key whose column AND field map exist becomes a recoverSpread item, not a silent skip', () => {
247
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
248
+ const items = resolve(promoter, ['roundId'], ['ID', 'roundId'], { roundId: 'roundId' });
249
+ expect(items).toHaveLength(1);
250
+ expect(items[0]).toMatchObject({ recoverSpread: true, needsColumn: false, needsFieldMap: false, columnName: 'roundId' });
251
+ });
252
+
253
+ it('a brand-new key still plans column + field map, with no recovery flag', () => {
254
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
255
+ const items = resolve(promoter, ['newKey'], ['ID'], {});
256
+ expect(items).toHaveLength(1);
257
+ expect(items[0]).toMatchObject({ needsColumn: true, needsFieldMap: true });
258
+ expect(items[0].recoverSpread).toBeFalsy();
259
+ });
260
+
261
+ it('a collision-suffixed column is found via the field map, not re-promoted', () => {
262
+ // The real column is `roundId_2` — re-sanitizing 'roundId' can never produce that name, so the
263
+ // old check read "no column yet" and minted a THIRD column beside the working one.
264
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
265
+ const items = resolve(promoter, ['roundId'], ['ID', 'roundId_2'], { roundId: 'roundId_2' });
266
+ expect(items).toHaveLength(1);
267
+ expect(items[0]).toMatchObject({ recoverSpread: true, needsColumn: false, columnName: 'roundId_2' });
268
+ });
269
+
270
+ it('a field map whose column is missing from stale in-process metadata is still terminal', () => {
271
+ // entityInfo predates the ADD COLUMN in THIS process. The map names the column, so the key is
272
+ // already promoted — planning another column for it is the duplicate-column bug.
273
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
274
+ const items = resolve(promoter, ['roundId'], ['ID'], { roundId: 'roundId' });
275
+ expect(items).toHaveLength(1);
276
+ expect(items[0]).toMatchObject({ needsColumn: false, needsFieldMap: false, columnName: 'roundId' });
277
+ });
278
+ });
279
+
280
+ describe('purgeOneRow — what the per-pass budget actually spends', () => {
281
+ function makeRow(values: Record<string, unknown>) {
282
+ const data = { ...values };
283
+ return {
284
+ Get: vi.fn((f: string) => data[f]),
285
+ Set: vi.fn((f: string, v: unknown) => { data[f] = v; }),
286
+ Save: vi.fn().mockResolvedValue(true),
287
+ _data: data,
288
+ };
289
+ }
290
+ function purge(row: ReturnType<typeof makeRow>, stale: Array<{ sourceKey: string; columnName: string }>) {
291
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
292
+ return (promoter as unknown as {
293
+ purgeOneRow: (r: unknown, s: unknown, h: boolean, m: string[]) => Promise<{ wrote: boolean; leftSet: boolean }>;
294
+ }).purgeOneRow(row, stale, false, []);
295
+ }
296
+
297
+ it('a row an earlier pass already cleaned costs NO budget and no write', async () => {
298
+ // This is what lets a capped pass reach residue further down the table: already-purged rows
299
+ // fall through in memory instead of being re-written.
300
+ const row = makeRow({ __mj_integration_CustomFields: JSON.stringify({ somethingElse: 1 }) });
301
+ const res = await purge(row, [{ sourceKey: 'eventCode', columnName: 'eventCode' }]);
302
+ expect(res).toEqual({ wrote: false, leftSet: false });
303
+ expect(row.Save).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it('a row that keeps other unmapped keys is WRITTEN but stays in the filtered set', async () => {
307
+ // wrote and leftSet are different questions: the first spends budget, the second shifts the
308
+ // paged offsets. Conflating them made the walk skip rows.
309
+ const row = makeRow({
310
+ __mj_integration_CustomFields: JSON.stringify({ eventCode: 'ABC', notYetPromoted: 'x' }),
311
+ eventCode: null,
312
+ });
313
+ const res = await purge(row, [{ sourceKey: 'eventCode', columnName: 'eventCode' }]);
314
+ expect(res).toEqual({ wrote: true, leftSet: false });
315
+ expect(row._data['eventCode']).toBe('ABC');
316
+ expect(JSON.parse(row._data['__mj_integration_CustomFields'] as string)).toEqual({ notYetPromoted: 'x' });
317
+ });
318
+
319
+ it('the last key leaving takes the row out of the set', async () => {
320
+ const row = makeRow({ __mj_integration_CustomFields: JSON.stringify({ eventCode: 'ABC' }), eventCode: null });
321
+ const res = await purge(row, [{ sourceKey: 'eventCode', columnName: 'eventCode' }]);
322
+ expect(res).toEqual({ wrote: true, leftSet: true });
323
+ expect(row._data['__mj_integration_CustomFields']).toBeNull();
324
+ });
325
+
326
+ it('never overwrites a column that already has a value, but still strips the key', async () => {
327
+ const row = makeRow({ __mj_integration_CustomFields: JSON.stringify({ eventCode: 'ABC' }), eventCode: 'SETTLED' });
328
+ const res = await purge(row, [{ sourceKey: 'eventCode', columnName: 'eventCode' }]);
329
+ expect(row._data['eventCode']).toBe('SETTLED');
330
+ expect(res.leftSet).toBe(true);
331
+ });
332
+ });
333
+
334
+ describe('spreadOneRow — idempotent backfill', () => {
335
+ function makeRow(values: Record<string, unknown>) {
336
+ const data = { ...values };
337
+ return {
338
+ Get: vi.fn((f: string) => data[f]),
339
+ Set: vi.fn((f: string, v: unknown) => { data[f] = v; }),
340
+ Save: vi.fn().mockResolvedValue(true),
341
+ _data: data,
342
+ };
343
+ }
344
+
345
+ async function spread(row: ReturnType<typeof makeRow>, named: Array<ReturnType<typeof workItem>>) {
346
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
347
+ await (promoter as unknown as {
348
+ spreadOneRow: (r: unknown, n: unknown, h: boolean, m: string[]) => Promise<boolean>;
349
+ }).spreadOneRow(row, named, false, []);
350
+ }
351
+
352
+ it('fills a still-null destination from the overflow JSON, strips the key, and saves', async () => {
353
+ const row = makeRow({ __mj_integration_CustomFields: JSON.stringify({ roundId: 42 }), roundId: null });
354
+ await spread(row, [workItem('roundId')]);
355
+ expect(row._data['roundId']).toBe(42);
356
+ // The key was the only one staged, so the column goes null — the same shape the engine's
357
+ // own eviction writes, and what drops this row out of the `IS NOT NULL` scan.
358
+ expect(row._data['__mj_integration_CustomFields']).toBeNull();
359
+ expect(row.Save).toHaveBeenCalledTimes(1);
360
+ });
361
+
362
+ it('leaves an already-backfilled VALUE alone but still strips the key', async () => {
363
+ // The destination is settled, so the value is not rewritten. The key is removed regardless:
364
+ // it has a column and an active field map, so it is no longer unmapped, and leaving it staged
365
+ // is exactly what re-offers it as a phantom new column forever.
366
+ const row = makeRow({ __mj_integration_CustomFields: JSON.stringify({ roundId: 42 }), roundId: 99 });
367
+ await spread(row, [workItem('roundId', { recoverSpread: true })]);
368
+ expect(row._data['roundId']).toBe(99);
369
+ expect(row._data['__mj_integration_CustomFields']).toBeNull();
370
+ expect(row.Save).toHaveBeenCalledTimes(1);
371
+ });
372
+
373
+ it('keeps the still-unmapped keys and reports the row as staying in the scan', async () => {
374
+ const row = makeRow({
375
+ __mj_integration_CustomFields: JSON.stringify({ roundId: 42, notYetPromoted: 'x' }),
376
+ roundId: null,
377
+ });
378
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
379
+ const left = await (promoter as unknown as {
380
+ spreadOneRow: (r: unknown, n: unknown, h: boolean, m: string[]) => Promise<boolean>;
381
+ }).spreadOneRow(row, [workItem('roundId')], false, []);
382
+ expect(JSON.parse(row._data['__mj_integration_CustomFields'] as string)).toEqual({ notYetPromoted: 'x' });
383
+ // Still carries overflow, so it is still in the filtered set — the paged walk must NOT count
384
+ // it as removed or it will skip a later row.
385
+ expect(left).toBe(false);
386
+ });
387
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * An apply rebuilds its source schema from ACTIVE IntegrationObject/IntegrationObjectField rows
3
+ * only. Declared rows that a rediscovery deactivated are therefore dropped from the schema it
4
+ * materializes — the column never appears, or a requested object is never created — and the apply
5
+ * output said nothing about it. These tests pin what the operator is now told, and what they are
6
+ * deliberately NOT told, since an unfiltered apply on a large catalog can carry hundreds of
7
+ * legitimately deactivated objects.
8
+ */
9
+ import { describe, it, expect } from 'vitest';
10
+ import { ComputeInactiveRowWarnings, SummarizeNames } from '../integration/InactiveRowWarnings.js';
11
+
12
+ const active = (Name: string) => ({ Name, Status: 'Active' });
13
+ const disabled = (Name: string) => ({ Name, Status: 'Disabled' });
14
+
15
+ describe('ComputeInactiveRowWarnings', () => {
16
+ it('says nothing when every declared row in scope is Active', () => {
17
+ expect(ComputeInactiveRowWarnings({
18
+ RequestedNames: ['Contacts'],
19
+ AllObjects: [active('Contacts')],
20
+ FieldsByObjectName: { Contacts: [active('ID'), active('Email')] },
21
+ })).toEqual([]);
22
+ });
23
+
24
+ it('names the deactivated fields of an object it DOES materialize', () => {
25
+ const [warning, ...rest] = ComputeInactiveRowWarnings({
26
+ RequestedNames: ['Contacts'],
27
+ AllObjects: [active('Contacts')],
28
+ FieldsByObjectName: { Contacts: [active('ID'), disabled('MiddleName')] },
29
+ });
30
+ expect(rest).toEqual([]);
31
+ expect(warning).toContain('Contacts:');
32
+ expect(warning).toContain('1 declared field(s)');
33
+ expect(warning).toContain('MiddleName (Disabled)');
34
+ });
35
+
36
+ it('reports a requested object that is not Active — the caller named it and got nothing', () => {
37
+ const warnings = ComputeInactiveRowWarnings({
38
+ RequestedNames: ['Contacts', 'Invoices'],
39
+ AllObjects: [active('Contacts'), disabled('Invoices')],
40
+ FieldsByObjectName: { Contacts: [active('ID')] },
41
+ });
42
+ expect(warnings).toHaveLength(1);
43
+ expect(warnings[0]).toContain('Invoices (Disabled)');
44
+ expect(warnings[0]).toContain('1 requested object(s)');
45
+ });
46
+
47
+ it('matches requested names case-insensitively, the way the apply path resolves them', () => {
48
+ const warnings = ComputeInactiveRowWarnings({
49
+ RequestedNames: ['invoices'],
50
+ AllObjects: [disabled('Invoices')],
51
+ FieldsByObjectName: {},
52
+ });
53
+ expect(warnings[0]).toContain('Invoices (Disabled)');
54
+ });
55
+
56
+ it('stays quiet about deactivated objects when nothing was requested by name', () => {
57
+ // The unfiltered apply: hundreds of deactivated audit objects are the normal state of a
58
+ // large catalog, and announcing them on every apply is noise that buries the field warning.
59
+ const warnings = ComputeInactiveRowWarnings({
60
+ RequestedNames: null,
61
+ AllObjects: [active('Contacts'), disabled('Invoices'), disabled('Invoices__History')],
62
+ FieldsByObjectName: { Contacts: [active('ID')] },
63
+ });
64
+ expect(warnings).toEqual([]);
65
+ });
66
+
67
+ it('treats any non-Active status as not materialized, and shows which one it is', () => {
68
+ const warnings = ComputeInactiveRowWarnings({
69
+ RequestedNames: null,
70
+ AllObjects: [active('Contacts')],
71
+ FieldsByObjectName: { Contacts: [{ Name: 'Legacy', Status: 'Deprecated' }, { Name: 'Blank', Status: null }] },
72
+ });
73
+ expect(warnings[0]).toContain('Legacy (Deprecated)');
74
+ expect(warnings[0]).toContain('Blank (no status)');
75
+ });
76
+ });
77
+
78
+ describe('SummarizeNames', () => {
79
+ it('lists everything when the list is short', () => {
80
+ expect(SummarizeNames(['a', 'b', 'c'])).toBe('a, b, c');
81
+ });
82
+
83
+ it('caps a long list and says how many were left out', () => {
84
+ const names = Array.from({ length: 15 }, (_, i) => `f${i}`);
85
+ expect(SummarizeNames(names)).toBe(`${names.slice(0, 12).join(', ')}, and 3 more`);
86
+ });
87
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { BuildReactivateMessage } from '../integration/SchemaRefreshLaunch.js';
3
+ import type { SchemaRefreshSummaryLike } from '../integration/SchemaRefreshLaunch.js';
4
+
5
+ /**
6
+ * Reactivation is committed BEFORE the refresh runs, so no refresh outcome — running, failed, or
7
+ * clean — may ever read as "resume didn't work". These pin that, plus the two reporting bugs the
8
+ * create/update paths already fixed and reactivate had kept its own copy of:
9
+ *
10
+ * 1. A detached run's placeholder zeros must not be reported as findings ("0 created, 0 updated"
11
+ * tells an operator the source had nothing new, when in fact nothing has been looked at yet).
12
+ * 2. A pipeline that fails RETURNS with every count at zero rather than throwing, so counts alone
13
+ * read identically to a clean no-op run.
14
+ */
15
+ function summary(over: Partial<SchemaRefreshSummaryLike> = {}): SchemaRefreshSummaryLike {
16
+ return {
17
+ RunID: 'connector-1783000000000-abc123',
18
+ InProgress: false,
19
+ Succeeded: true,
20
+ ObjectsCreated: 12,
21
+ ObjectsUpdated: 3,
22
+ UnresolvedObjects: ['Widgets'],
23
+ ...over,
24
+ };
25
+ }
26
+
27
+ describe('BuildReactivateMessage', () => {
28
+ it('leads with the reactivation on every path', () => {
29
+ const paths = [
30
+ BuildReactivateMessage(undefined),
31
+ BuildReactivateMessage(summary({ InProgress: true, ObjectsCreated: 0, ObjectsUpdated: 0, UnresolvedObjects: [] })),
32
+ BuildReactivateMessage(summary()),
33
+ BuildReactivateMessage(summary({ Succeeded: false, FailureMessage: 'boom' })),
34
+ ];
35
+ for (const msg of paths) expect(msg).toMatch(/^Reactivated/);
36
+ });
37
+
38
+ it('names the run to tail — and reports NO counts — for a detached refresh', () => {
39
+ const msg = BuildReactivateMessage(summary({
40
+ InProgress: true,
41
+ ObjectsCreated: 0,
42
+ ObjectsUpdated: 0,
43
+ UnresolvedObjects: [],
44
+ }));
45
+
46
+ expect(msg).toContain('connector-1783000000000-abc123');
47
+ expect(msg).toMatch(/running/i);
48
+ expect(msg).not.toMatch(/\d+ created/);
49
+ expect(msg).not.toMatch(/\d+ updated/);
50
+ expect(msg).not.toMatch(/PK-unresolved/);
51
+ });
52
+
53
+ it('reports real counts when the caller asked to block', () => {
54
+ const msg = BuildReactivateMessage(summary());
55
+ expect(msg).toContain('12 created');
56
+ expect(msg).toContain('3 updated');
57
+ expect(msg).toContain('1 PK-unresolved');
58
+ expect(msg).not.toMatch(/running/i);
59
+ });
60
+
61
+ it('never reports a FAILED refresh as a zero-count success', () => {
62
+ // The old inline message formatted counts unconditionally, so a pipeline that failed at
63
+ // ConnectionTest produced "Reactivated, schema refresh: 0 created, 0 updated,
64
+ // 0 PK-unresolved" — indistinguishable from a source with nothing new.
65
+ const msg = BuildReactivateMessage(summary({
66
+ Succeeded: false,
67
+ FailureMessage: 'ConnectionTest failed: No Totara credentials found',
68
+ ObjectsCreated: 0,
69
+ ObjectsUpdated: 0,
70
+ UnresolvedObjects: [],
71
+ }));
72
+
73
+ expect(msg).toMatch(/FAILED/);
74
+ expect(msg).toContain('No Totara credentials found');
75
+ expect(msg).not.toMatch(/0 created/);
76
+ // ...and still says the connection is active, because it is.
77
+ expect(msg).toMatch(/^Reactivated/);
78
+ });
79
+
80
+ it('says nothing about a refresh that was never requested', () => {
81
+ const msg = BuildReactivateMessage(undefined);
82
+ expect(msg).toBe('Reactivated');
83
+ expect(msg).not.toMatch(/refresh/i);
84
+ });
85
+ });