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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/LICENSE +180 -4
  2. package/dist/agentSessions/SessionManager.d.ts +19 -5
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +27 -9
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
  7. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
  8. package/dist/auth/AuthProviderCatalogRouter.js +69 -0
  9. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
  10. package/dist/auth/AuthProviderEngine.d.ts +96 -0
  11. package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
  12. package/dist/auth/AuthProviderEngine.js +245 -0
  13. package/dist/auth/AuthProviderEngine.js.map +1 -0
  14. package/dist/auth/index.d.ts +12 -0
  15. package/dist/auth/index.d.ts.map +1 -1
  16. package/dist/auth/index.js +57 -13
  17. package/dist/auth/index.js.map +1 -1
  18. package/dist/auth/initializeProviders.d.ts +64 -1
  19. package/dist/auth/initializeProviders.d.ts.map +1 -1
  20. package/dist/auth/initializeProviders.js +117 -5
  21. package/dist/auth/initializeProviders.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +89 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +52 -35
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts.map +1 -1
  31. package/dist/context.js +21 -1
  32. package/dist/context.js.map +1 -1
  33. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  34. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
  35. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  36. package/dist/generated/generated.d.ts +619 -1597
  37. package/dist/generated/generated.d.ts.map +1 -1
  38. package/dist/generated/generated.js +3341 -17021
  39. package/dist/generated/generated.js.map +1 -1
  40. package/dist/generic/ResolverBase.d.ts +28 -1
  41. package/dist/generic/ResolverBase.d.ts.map +1 -1
  42. package/dist/generic/ResolverBase.js +57 -8
  43. package/dist/generic/ResolverBase.js.map +1 -1
  44. package/dist/generic/RunViewResolver.d.ts +4 -0
  45. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  46. package/dist/generic/RunViewResolver.js +31 -0
  47. package/dist/generic/RunViewResolver.js.map +1 -1
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +147 -14
  51. package/dist/index.js.map +1 -1
  52. package/dist/integration/CustomColumnPromoter.d.ts +79 -15
  53. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  54. package/dist/integration/CustomColumnPromoter.js +472 -97
  55. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  56. package/dist/integration/InactiveRowWarnings.d.ts +48 -0
  57. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
  58. package/dist/integration/InactiveRowWarnings.js +57 -0
  59. package/dist/integration/InactiveRowWarnings.js.map +1 -0
  60. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  61. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  62. package/dist/integration/RSUProgressBridge.js +230 -0
  63. package/dist/integration/RSUProgressBridge.js.map +1 -0
  64. package/dist/integration/SchemaRefreshLaunch.d.ts +62 -0
  65. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  66. package/dist/integration/SchemaRefreshLaunch.js +67 -0
  67. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  68. package/dist/providerConfigUnits.d.ts +20 -0
  69. package/dist/providerConfigUnits.d.ts.map +1 -0
  70. package/dist/providerConfigUnits.js +22 -0
  71. package/dist/providerConfigUnits.js.map +1 -0
  72. package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
  73. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  74. package/dist/resolvers/AdhocQueryResolver.js +20 -6
  75. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  76. package/dist/resolvers/FileResolver.d.ts +34 -4
  77. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  78. package/dist/resolvers/FileResolver.js +218 -28
  79. package/dist/resolvers/FileResolver.js.map +1 -1
  80. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
  81. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
  82. package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
  83. package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
  84. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +108 -3
  85. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  86. package/dist/resolvers/IntegrationDiscoveryResolver.js +374 -87
  87. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  88. package/dist/resolvers/QueryResolver.d.ts +5 -4
  89. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  90. package/dist/resolvers/QueryResolver.js +29 -14
  91. package/dist/resolvers/QueryResolver.js.map +1 -1
  92. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  93. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  94. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  95. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  96. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  97. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  98. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  99. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  100. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  101. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  102. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  103. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  104. package/dist/resolvers/UserResolver.d.ts +19 -17
  105. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  106. package/dist/resolvers/UserResolver.js +66 -67
  107. package/dist/resolvers/UserResolver.js.map +1 -1
  108. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  109. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  110. package/dist/resolvers/currentUserRoles.js +36 -0
  111. package/dist/resolvers/currentUserRoles.js.map +1 -0
  112. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  113. package/dist/resolvers/meetingRecordingRegistration.js +3 -6
  114. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  115. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  116. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  117. package/dist/rest/MediaAccessKeys.js +42 -1
  118. package/dist/rest/MediaAccessKeys.js.map +1 -1
  119. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  120. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  121. package/dist/rest/MediaStreamHandler.js +83 -2
  122. package/dist/rest/MediaStreamHandler.js.map +1 -1
  123. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  124. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  125. package/dist/rest/OAuthCallbackHandler.js +129 -43
  126. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  127. package/dist/rest/UploadTokenManager.d.ts +90 -0
  128. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  129. package/dist/rest/UploadTokenManager.js +156 -0
  130. package/dist/rest/UploadTokenManager.js.map +1 -0
  131. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  132. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  133. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  134. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  135. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  136. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  137. package/dist/services/StartTaskGraphDispatcher.js +21 -1
  138. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  139. package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
  140. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  141. package/dist/services/TaskGraphAgentRunner.js +23 -0
  142. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  143. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  144. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  145. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  146. package/dist/types.d.ts +14 -0
  147. package/dist/types.d.ts.map +1 -1
  148. package/dist/types.js.map +1 -1
  149. package/package.json +92 -92
  150. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  151. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  152. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  153. package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
  154. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  155. package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
  156. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  157. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  158. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  159. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  160. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  161. package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
  162. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  163. package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
  164. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  165. package/src/__tests__/SessionManager.test.ts +55 -0
  166. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  167. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  168. package/src/__tests__/config-units.test.ts +74 -0
  169. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  170. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  171. package/src/__tests__/newUsers.test.ts +726 -0
  172. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  173. package/src/agentSessions/SessionManager.ts +27 -9
  174. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  175. package/src/auth/AuthProviderEngine.ts +253 -0
  176. package/src/auth/index.ts +60 -15
  177. package/src/auth/initializeProviders.ts +128 -7
  178. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  179. package/src/config.ts +55 -36
  180. package/src/context.ts +26 -2
  181. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
  182. package/src/generated/generated.ts +2347 -10551
  183. package/src/generic/ResolverBase.ts +67 -8
  184. package/src/generic/RunViewResolver.ts +31 -0
  185. package/src/index.ts +165 -16
  186. package/src/integration/CustomColumnPromoter.ts +520 -98
  187. package/src/integration/InactiveRowWarnings.ts +85 -0
  188. package/src/integration/RSUProgressBridge.ts +248 -0
  189. package/src/integration/SchemaRefreshLaunch.ts +93 -0
  190. package/src/providerConfigUnits.ts +21 -0
  191. package/src/resolvers/AdhocQueryResolver.ts +21 -6
  192. package/src/resolvers/FileResolver.ts +200 -40
  193. package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
  194. package/src/resolvers/IntegrationDiscoveryResolver.ts +390 -71
  195. package/src/resolvers/QueryResolver.ts +25 -11
  196. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  197. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  198. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  199. package/src/resolvers/UserResolver.ts +99 -60
  200. package/src/resolvers/currentUserRoles.ts +47 -0
  201. package/src/resolvers/meetingRecordingRegistration.ts +3 -7
  202. package/src/rest/MediaAccessKeys.ts +47 -1
  203. package/src/rest/MediaStreamHandler.ts +98 -3
  204. package/src/rest/OAuthCallbackHandler.ts +149 -41
  205. package/src/rest/UploadTokenManager.ts +211 -0
  206. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  207. package/src/services/StartTaskGraphDispatcher.ts +21 -1
  208. package/src/services/TaskGraphAgentRunner.ts +23 -0
  209. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  210. package/src/types.ts +14 -0
@@ -31,7 +31,7 @@ import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
31
31
  import { RunViewGenericParams, UserPayload } from '../types.js';
32
32
  import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
33
33
  import { DeleteOptionsInput } from './DeleteOptionsInput.js';
34
- import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
34
+ import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
35
35
  import { EncryptionEngine } from '@memberjunction/encryption';
36
36
  import { PUSH_STATUS_UPDATES_TOPIC, publishStatusUpdate } from './PushStatusResolver.js';
37
37
  import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
@@ -341,6 +341,58 @@ export class ResolverBase {
341
341
  return dataObjectArray;
342
342
  }
343
343
 
344
+ /**
345
+ * Renders one `findBy` predicate value as the SQL literal text it will become inside the
346
+ * `ExtraFilter` that method builds.
347
+ *
348
+ * The two slots have different exposure, so they are handled differently:
349
+ *
350
+ * - **Quoted slot** (`NeedsQuotes` — every type except Number and Boolean). The value sits
351
+ * inside a single-quoted literal, so it is escaped with `EscapeSQLString`: quotes are
352
+ * doubled and null bytes stripped, leaving the payload inert *inside* the literal.
353
+ * - **Unquoted slot** (Number and Boolean fields). There is no literal to break out of
354
+ * here — the value lands in the clause bare, so a string value simply *is* SQL and no
355
+ * amount of quote escaping helps. Only a genuine number or boolean is accepted; anything
356
+ * else is refused rather than interpolated. Numeric and boolean strings are accepted too
357
+ * (a client may legitimately send `"42"`), since neither can carry SQL.
358
+ *
359
+ * A `null`/`undefined` value is refused in either slot. `EscapeSQLString` maps both to `''`
360
+ * by design, which would silently turn a by-value lookup into `Field = ''` — for this
361
+ * caller a missing value is a bug, not a query.
362
+ *
363
+ * @param value the raw value supplied for the field
364
+ * @param fieldName field name, for the error message
365
+ * @param entity entity name, for the error message
366
+ * @param needsQuotes whether the field's type requires a quoted literal
367
+ * @returns the literal text (including quotes when the slot is quoted)
368
+ * @throws when the value cannot be rendered safely for the slot it is destined for
369
+ */
370
+ private formatFilterValue(value: unknown, fieldName: string, entity: string, needsQuotes: boolean): string {
371
+ if (value === null || value === undefined) {
372
+ throw new Error(
373
+ `Field ${fieldName} in entity ${entity} was given a ${value === null ? 'null' : 'undefined'} value. findBy builds an equality predicate, which needs a value to compare against.`
374
+ );
375
+ }
376
+
377
+ if (needsQuotes) {
378
+ return `'${EscapeSQLString(String(value))}'`;
379
+ }
380
+
381
+ // Unquoted slot — accept only what cannot carry SQL, and render it exactly as before.
382
+ if (typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) {
383
+ return String(value);
384
+ }
385
+ if (typeof value === 'string') {
386
+ const trimmed = value.trim();
387
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
388
+ if (/^(true|false)$/i.test(trimmed)) return trimmed.toLowerCase();
389
+ }
390
+
391
+ throw new Error(
392
+ `Field ${fieldName} in entity ${entity} is a numeric or boolean field, so its value is not quoted in the filter — only a number or boolean can be used. Received: ${typeof value}.`
393
+ );
394
+ }
395
+
344
396
  protected async findBy<T = any>(provider: DatabaseProviderBase, entity: string, params: any, contextUser: UserInfo): Promise<Array<T>> {
345
397
  // build the SQL query based on the params passed in
346
398
  const rv = provider as any as IRunViewProvider;
@@ -354,8 +406,7 @@ export class ResolverBase {
354
406
  // look up the field in the entityInfo to see if it needs quotes
355
407
  const field = e.Fields.find((f) => f.Name === k);
356
408
  if (!field) throw new Error(`Field ${k} not found in entity ${entity}`);
357
- const quotes = field.NeedsQuotes ? "'" : '';
358
- extraFilter += `${k} = ${quotes}${params[k]}${quotes}`;
409
+ extraFilter += `${k} = ${this.formatFilterValue(params[k], k, entity, field.NeedsQuotes)}`;
359
410
  });
360
411
 
361
412
  // ok, now we have a SQL string, run it and return the results
@@ -382,7 +433,8 @@ export class ResolverBase {
382
433
  const rv = provider as any as IRunViewProvider;
383
434
  const result = await rv.RunView<MJUserViewEntityExtended>({
384
435
  EntityName: 'MJ: User Views',
385
- ExtraFilter: "Name='" + viewInput.ViewName + "'",
436
+ // SECURITY: the view name is client-supplied and lands inside a SQL literal.
437
+ ExtraFilter: "Name='" + EscapeSQLString(viewInput.ViewName) + "'",
386
438
  }, userPayload.userRecord);
387
439
  if (result && result.Success && result.Results.length > 0) {
388
440
  const viewInfo = result.Results[0];
@@ -408,7 +460,8 @@ export class ResolverBase {
408
460
  viewInput.AfterKey
409
461
  ? CompositeKey.FromKeyValuePairs((viewInput.AfterKey as { KeyValuePairs: { FieldName: string; Value: string }[] }).KeyValuePairs)
410
462
  : undefined,
411
- viewInput.BypassCache
463
+ viewInput.BypassCache,
464
+ viewInput.DataSource
412
465
  );
413
466
  }
414
467
  else {
@@ -451,7 +504,8 @@ export class ResolverBase {
451
504
  viewInput.StartRow,
452
505
  viewInput.Aggregates,
453
506
  undefined,
454
- viewInput.BypassCache
507
+ viewInput.BypassCache,
508
+ viewInput.DataSource
455
509
  );
456
510
  } catch (err) {
457
511
  console.log(err);
@@ -497,7 +551,8 @@ export class ResolverBase {
497
551
  viewInput.StartRow,
498
552
  viewInput.Aggregates,
499
553
  undefined,
500
- viewInput.BypassCache
554
+ viewInput.BypassCache,
555
+ viewInput.DataSource
501
556
  );
502
557
  } catch (err) {
503
558
  console.log(err);
@@ -571,6 +626,7 @@ export class ResolverBase {
571
626
  userPayload,
572
627
  aggregates: viewInput.Aggregates,
573
628
  bypassCache: viewInput.BypassCache,
629
+ dataSource: viewInput.DataSource,
574
630
  });
575
631
  } catch (err) {
576
632
  LogError(err);
@@ -767,7 +823,8 @@ export class ResolverBase {
767
823
  startRow: number | undefined,
768
824
  aggregates?: AggregateExpression[],
769
825
  afterKey?: CompositeKey,
770
- bypassCache?: boolean
826
+ bypassCache?: boolean,
827
+ dataSource?: 'Live' | 'Materialized'
771
828
  ) {
772
829
  try {
773
830
  if (!viewInfo || !userPayload) return null;
@@ -837,6 +894,7 @@ export class ResolverBase {
837
894
  ResultType: rt,
838
895
  Aggregates: aggregates,
839
896
  BypassCache: bypassCache,
897
+ DataSource: dataSource,
840
898
  },
841
899
  user
842
900
  );
@@ -972,6 +1030,7 @@ export class ResolverBase {
972
1030
  ResultType: rt,
973
1031
  Aggregates: param.aggregates,
974
1032
  BypassCache: param.bypassCache,
1033
+ DataSource: param.dataSource,
975
1034
  });
976
1035
  }
977
1036
 
@@ -177,6 +177,13 @@ export class RunViewByIDInput {
177
177
  'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
178
178
  })
179
179
  BypassCache?: boolean;
180
+
181
+ @Field(() => String, {
182
+ nullable: true,
183
+ description:
184
+ "Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
185
+ })
186
+ DataSource?: 'Live' | 'Materialized';
180
187
  }
181
188
 
182
189
  @InputType()
@@ -291,6 +298,13 @@ export class RunViewByNameInput {
291
298
  'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
292
299
  })
293
300
  BypassCache?: boolean;
301
+
302
+ @Field(() => String, {
303
+ nullable: true,
304
+ description:
305
+ "Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
306
+ })
307
+ DataSource?: 'Live' | 'Materialized';
294
308
  }
295
309
 
296
310
  @InputType()
@@ -391,6 +405,13 @@ export class RunDynamicViewInput {
391
405
  'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
392
406
  })
393
407
  BypassCache?: boolean;
408
+
409
+ @Field(() => String, {
410
+ nullable: true,
411
+ description:
412
+ "Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
413
+ })
414
+ DataSource?: 'Live' | 'Materialized';
394
415
  }
395
416
 
396
417
  @InputType()
@@ -520,6 +541,13 @@ export class RunViewGenericInput {
520
541
  'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
521
542
  })
522
543
  BypassCache?: boolean;
544
+
545
+ @Field(() => String, {
546
+ nullable: true,
547
+ description:
548
+ "Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
549
+ })
550
+ DataSource?: 'Live' | 'Materialized';
523
551
  }
524
552
 
525
553
  //****************************************************************************
@@ -1082,6 +1110,9 @@ export class RunViewResolver extends ResolverBase {
1082
1110
  // Forward the aggregate request to the engine (B40) — omitted here as well as in the
1083
1111
  // client's input map, so aggregates never reached InternalRunView on this transport.
1084
1112
  Aggregates: item.params.Aggregates,
1113
+ // DataSource must be forwarded on this transport too (same as the InternalRunView/RunViews maps):
1114
+ // otherwise a CacheLocal batch's DataSource:'Materialized' request silently reads the live view.
1115
+ DataSource: item.params.DataSource,
1085
1116
  AfterKey: item.params.AfterKey
1086
1117
  ? CompositeKey.FromKeyValuePairs(item.params.AfterKey.KeyValuePairs)
1087
1118
  : undefined,
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ import { UserCache, resolveDbPlatformFromEnv } from '@memberjunction/generic-dat
9
9
  import { MJGlobal, MJEventType, UUIDsEqual, ShutdownRegistry } from '@memberjunction/global';
10
10
  import { setupSQLServerClient, SQLServerDataProvider, SQLServerProviderConfigData } from '@memberjunction/sqlserver-dataprovider';
11
11
  import { extendConnectionPoolWithQuery } from './util.js';
12
- import { registerIntegrationCustomColumnPromoter } from './integration/CustomColumnPromoter.js';
12
+ import { registerIntegrationCustomColumnPromoter, IntegrationCustomColumnPromoter } from './integration/CustomColumnPromoter.js';
13
13
  import { DisableUnselectedEntityMaps, ReenableFieldMapsForEntityMap } from './integration/EntityMapLifecycle.js';
14
14
  import { default as BodyParser } from 'body-parser';
15
15
  import compression from 'compression'; // Add compression middleware
@@ -57,14 +57,17 @@ import { DataSourceInfo, raiseEvent } from './types.js';
57
57
 
58
58
  import { ExternalChangeDetectorEngine } from '@memberjunction/external-change-detection';
59
59
  import { ScheduledJobsService } from './services/ScheduledJobsService.js';
60
+ import { IntegrationSyncWorkerService } from './services/IntegrationSyncWorkerService.js';
60
61
  import { LocalCacheManager, StartupManager, TelemetryManager, TelemetryLevel, LogStatus, SetVerboseLogging } from '@memberjunction/core';
61
- import { getSystemUser } from './auth/index.js';
62
+ import { getSystemUser, validateAuthProvidersRegistered } from './auth/index.js';
63
+ import { createAuthProviderCatalogRouter, AUTH_CATALOG_MOUNT_PATH } from './auth/AuthProviderCatalogRouter.js';
62
64
  import { GetAPIKeyEngine } from '@memberjunction/api-keys';
63
65
  import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
64
66
  import { GenericDatabaseProvider } from '@memberjunction/generic-database-provider';
65
67
  import { PubSubManager } from './generic/PubSubManager.js';
66
68
  import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
67
69
  import { PublishIntegrationProgress } from './resolvers/IntegrationProgressResolver.js';
70
+ import { RegisterRSUProgressBridge } from './integration/RSUProgressBridge.js';
68
71
  import { ClientToolRequestManager, AgentRunWatchdog } from '@memberjunction/ai-agents';
69
72
  import { SessionJanitor } from './agentSessions/index.js';
70
73
  import { StartTaskGraphDispatcher } from './services/StartTaskGraphDispatcher.js';
@@ -79,9 +82,12 @@ import {
79
82
  MJScheduledJobEntity,
80
83
  } from '@memberjunction/core-entities';
81
84
  import { ServerExtensionLoader, ServerExtensionConfig } from '@memberjunction/server-extensions-core';
85
+ import { MetadataCacheRefreshIntervalSeconds } from './providerConfigUnits.js';
82
86
 
83
87
  const cacheRefreshInterval = configInfo.databaseSettings.metadataCacheRefreshInterval;
84
88
 
89
+ export { MetadataCacheRefreshIntervalSeconds } from './providerConfigUnits.js';
90
+
85
91
  /**
86
92
  * Returns the configured database platform from the `DB_PLATFORM` environment
87
93
  * variable, falling back to `'sqlserver'` when the env var is unset. An
@@ -100,6 +106,10 @@ export function getDbType(): DatabasePlatform {
100
106
 
101
107
  export { MaxLength } from 'class-validator';
102
108
  export * from 'type-graphql';
109
+ // Named re-export so Open App generated resolvers get a live ESM binding for
110
+ // Int/Float/ID. `export *` from type-graphql can leave these undefined for
111
+ // later importers, which makes schema build fail on ViewResult.RowCount.
112
+ export { Int, Float, ID } from 'type-graphql';
103
113
  export { NewUserBase } from './auth/newUsers.js';
104
114
  export { configInfo, DEFAULT_SERVER_CONFIG } from './config.js';
105
115
  export { ServerExtensionLoader, BaseServerExtension } from '@memberjunction/server-extensions-core';
@@ -135,6 +145,7 @@ export * from './resolvers/RunClusterAnalysisResolver.js';
135
145
  export * from './resolvers/GenerateSeedTaxonomyResolver.js';
136
146
  export * from './resolvers/PipelineProgressResolver.js';
137
147
  export * from './resolvers/IntegrationProgressResolver.js';
148
+ export * from './resolvers/IdentityClaimRedemptionResolver.js';
138
149
  export * from './resolvers/ClientToolRequestResolver.js';
139
150
  export * from './resolvers/AutotagPipelineResolver.js';
140
151
  export * from './resolvers/TagGovernanceResolver.js';
@@ -204,6 +215,7 @@ import type { RequestHandler, ErrorRequestHandler } from 'express';
204
215
  import type { ApolloServerPlugin } from '@apollo/server';
205
216
  import type { GraphQLSchema } from 'graphql';
206
217
  import { BaseServerMiddleware } from './middleware/BaseServerMiddleware.js';
218
+ import { SuppressTaskGraphSubmission } from '@memberjunction/ai-core-plus';
207
219
 
208
220
  export type MJServerOptions = {
209
221
  onBeforeServe?: () => void | Promise<void>;
@@ -339,6 +351,10 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
339
351
  const pgStartupMode = ResolveStartupMode({ configValue: configInfo.startup?.mode, defaultMode: 'full' });
340
352
  await StartupManagerImport.Instance.Startup(false, sysUser || backupSysUser, provider, { mode: pgStartupMode.mode });
341
353
 
354
+ // Both provider sources have now had their turn — config/env at module load, metadata via
355
+ // AuthProviderEngine's startup hook — so "no providers at all" is finally a meaningful check.
356
+ validateAuthProvidersRegistered();
357
+
342
358
  // Monkey-patch SQLServerDataProvider.ExecuteSQLWithPool to support PostgreSQL
343
359
  // Generated resolvers call this static method with bracket-quoted SQL.
344
360
  // When the pool is our PG-compat wrapper, translate and execute via pg.Pool.
@@ -489,11 +505,16 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
489
505
  startupLog.LogIf('verbose', 'Read-only Connection Pool has been initialized.');
490
506
  }
491
507
 
492
- const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval / 1000); // convert ms to seconds (checkRefreshIntervalSeconds)
508
+ // cacheRefreshInterval is configured in ms; checkRefreshIntervalSeconds declares seconds see providerConfigUnits.ts
509
+ const config = new SQLServerProviderConfigData(pool, mj_core_schema, MetadataCacheRefreshIntervalSeconds(cacheRefreshInterval));
493
510
  // MJAPI is a long-running server, so entry-point default is 'full' engine pre-warm;
494
511
  // MJ_STARTUP_MODE / mj.config.cjs startup.mode can override per the shared precedence chain
495
512
  const startupMode = ResolveStartupMode({ configValue: configInfo.startup?.mode, defaultMode: 'full' });
496
513
  await setupSQLServerClient(config, { mode: startupMode.mode });
514
+
515
+ // See the note on the PostgreSQL path above: this is the first point at which both the
516
+ // config/env providers and the metadata catalog have been registered.
517
+ validateAuthProvidersRegistered();
497
518
  lap('Metadata + Provider Setup', tPhase);
498
519
  startupLog.BeginPhase('Initializing data provider');
499
520
  const md = new Metadata(); // global-provider-ok: bootstrap
@@ -524,7 +545,8 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
524
545
  await codegenPool.connect();
525
546
 
526
547
  const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
527
- const codegenConfig = new SQLServerProviderConfigData(codegenPool, mj_core_schema, cacheRefreshInterval / 1000); // convert ms to seconds (checkRefreshIntervalSeconds)
548
+ // Same ms→seconds seam as the main provider config above see providerConfigUnits.ts
549
+ const codegenConfig = new SQLServerProviderConfigData(codegenPool, mj_core_schema, MetadataCacheRefreshIntervalSeconds(cacheRefreshInterval));
528
550
  const codegenProvider = new SQLServerDataProvider();
529
551
  await codegenProvider.Config(codegenConfig);
530
552
  RuntimeSchemaManager.Instance.SetDDLProvider(codegenProvider);
@@ -835,6 +857,14 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
835
857
  })
836
858
  );
837
859
 
860
+ // Give Runtime Schema Update runs the same durable, tailable event stream as syncs and connector
861
+ // builds. `IntegrationRunKind` has always had an 'RSU' kind and RUN_KIND_TO_TOPIC has always
862
+ // mapped it to an 'RSU' channel, but nothing published to it — the only live signal was polling
863
+ // RuntimeSchemaUpdateStatus, which reports the current step and keeps no history, and which goes
864
+ // silent entirely across the API restart the pipeline performs on itself. Registered AFTER the
865
+ // publish hook above so the first RSU event also reaches live subscribers.
866
+ RegisterRSUProgressBridge();
867
+
838
868
  // Global listener: broadcast CACHE_INVALIDATION to all browser clients whenever
839
869
  // ANY BaseEntity save/delete occurs on this server — regardless of whether it
840
870
  // originated from a GraphQL mutation or internal server-side code (agents, actions,
@@ -1107,7 +1137,9 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1107
1137
  const { callbackRouter, authenticatedRouter } = createOAuthCallbackHandler({
1108
1138
  publicUrl: oauthPublicUrl,
1109
1139
  successRedirectUrl: `${oauthPublicUrl}/oauth/success`,
1110
- errorRedirectUrl: `${oauthPublicUrl}/oauth/error`
1140
+ errorRedirectUrl: `${oauthPublicUrl}/oauth/error`,
1141
+ // Constrains where a caller-supplied frontendReturnUrl may point (open-redirect guard).
1142
+ allowedFrontendOrigins: configInfo.cors?.allowedOrigins ?? ['*']
1111
1143
  });
1112
1144
  oauthAuthenticatedRouter = authenticatedRouter;
1113
1145
 
@@ -1256,6 +1288,14 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1256
1288
  res.status(allHealthy ? 200 : 503).json({ extensions: results });
1257
1289
  });
1258
1290
 
1291
+ // ─── Public authentication-provider catalog (PUBLIC, before auth mw) ──────
1292
+ // The browser needs the provider list BEFORE it holds a token, so this is necessarily
1293
+ // unauthenticated and must mount ahead of the auth middleware. It publishes only the
1294
+ // non-secret allow-list (see AuthProviderEngine.GetPublicCatalog) — the same values a
1295
+ // single-provider SPA already compiled into its bundle.
1296
+ app.use(AUTH_CATALOG_MOUNT_PATH, cors<cors.CorsRequest>(), createAuthProviderCatalogRouter());
1297
+ startupLog.LogIf('verbose', `[Auth] Public provider catalog registered at ${AUTH_CATALOG_MOUNT_PATH}/providers`);
1298
+
1259
1299
  // ─── Unified auth middleware (replaces both REST authMiddleware and contextFunction auth) ─────
1260
1300
  app.use(createUnifiedAuthMiddleware(dataSources));
1261
1301
 
@@ -1354,6 +1394,20 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1354
1394
  }
1355
1395
  }
1356
1396
 
1397
+ // Initialize and start the integration sync worker if enabled (PR 1 item 8).
1398
+ // Off by default — deployments that don't opt in keep running syncs inline.
1399
+ let integrationSyncWorker: IntegrationSyncWorkerService | null = null;
1400
+ if (configInfo.integrationSyncWorker?.enabled) {
1401
+ try {
1402
+ integrationSyncWorker = new IntegrationSyncWorkerService(configInfo.integrationSyncWorker);
1403
+ await integrationSyncWorker.Initialize();
1404
+ integrationSyncWorker.Start();
1405
+ } catch (error) {
1406
+ console.error('❌ Failed to start integration sync worker:', error);
1407
+ // Don't throw — an unavailable worker must not prevent the API from serving
1408
+ }
1409
+ }
1410
+
1357
1411
  // Data hooks are now registered via BaseServerMiddleware classes above
1358
1412
  // (e.g., MJTenantFilterMiddleware registers PreRunView and PreSave hooks).
1359
1413
  // No config-bag hook registration needed.
@@ -1425,7 +1479,15 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1425
1479
  const taskGraphPool = dataSources[0]?.dataSource;
1426
1480
  const taskGraphDispatcherDisabled = process.env.MJ_DISABLE_TASK_GRAPH_DISPATCHER === '1';
1427
1481
  if (taskGraphDispatcherDisabled) {
1428
- LogStatus('[TaskGraphDispatcher] Disabled by MJ_DISABLE_TASK_GRAPH_DISPATCHER=1 submitted graphs will not be executed by this process.');
1482
+ // AND REFUSE SUBMISSIONS, not just execution (R3-11). The durable submitter registers through
1483
+ // the generated manifest unconditionally, so without this the host went on ACCEPTING graphs it
1484
+ // had no intention of running: the agent submitted, promised the user a follow-up, and parked
1485
+ // its run `Paused` — with the graph `Pending` and the run parked forever, no per-submission
1486
+ // diagnostics anywhere, and the stale graph executing hours later if anyone unset the flag.
1487
+ // The entity-action seam already had this treatment (its submitter registers inside
1488
+ // StartTaskGraphDispatcher); this gives the agent seam the same.
1489
+ SuppressTaskGraphSubmission('MJ_DISABLE_TASK_GRAPH_DISPATCHER=1 is set on this host');
1490
+ LogStatus('[TaskGraphDispatcher] Disabled by MJ_DISABLE_TASK_GRAPH_DISPATCHER=1 — this process will neither accept nor execute task graphs.');
1429
1491
  } else if (resumeUser && taskGraphPool instanceof sql.ConnectionPool) {
1430
1492
  StartTaskGraphDispatcher(taskGraphPool, resumeUser)
1431
1493
  .catch(err => console.warn(`[TaskGraphDispatcher] Startup failed: ${err}`));
@@ -1472,6 +1534,17 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1472
1534
  }
1473
1535
  }
1474
1536
 
1537
+ // Stop the integration sync worker's polling. In-flight runs finish on their own —
1538
+ // their leases are heartbeat-renewed, and killing them here would only strand rows.
1539
+ if (integrationSyncWorker?.IsRunning) {
1540
+ try {
1541
+ integrationSyncWorker.Stop();
1542
+ console.log(`✅ Integration sync worker stopped (${integrationSyncWorker.InFlightCount} run(s) still in flight)`);
1543
+ } catch (error) {
1544
+ console.error('❌ Error stopping integration sync worker:', error);
1545
+ }
1546
+ }
1547
+
1475
1548
  // Drain anything self-registered with ShutdownRegistry — QueueManager,
1476
1549
  // future engines/services with timers/intervals/listeners. Each is
1477
1550
  // responsible for being idempotent and not throwing.
@@ -1510,15 +1583,75 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1510
1583
  });
1511
1584
  };
1512
1585
 
1586
+ /**
1587
+ * Age at which an unprocessed `MJ: RSU Pending Works` row is reported as stranded.
1588
+ * A row older than this survived at least one full restart cycle without being completed.
1589
+ */
1590
+ const RSU_PENDING_WORK_STALE_MINUTES = 30;
1591
+
1513
1592
  /**
1514
1593
  * Process pending RSU work left from a pre-restart Apply All.
1515
- * Reads pending work files, creates entity maps + field maps, starts sync.
1594
+ * Reads the durable `MJ: RSU Pending Works` queue, creates entity maps + field maps,
1595
+ * starts sync, and marks each row Completed only AFTER its work actually succeeded —
1596
+ * so a crash mid-processing leaves the row Pending and re-processable on the next boot.
1516
1597
  */
1598
+ /**
1599
+ * Complete a custom-column promotion whose DDL landed before the restart.
1600
+ *
1601
+ * Promotion registers this rather than finishing inline, because the restart is what loads the
1602
+ * regenerated entity classes — so the IntegrationObjectField rows, the field maps and the
1603
+ * overflow→column spread all belong on this side of it, where the columns are real typed
1604
+ * properties rather than dynamic .Get/.Set.
1605
+ *
1606
+ * The work itself lives on the promoter, so promotion logic stays in one class.
1607
+ */
1608
+ async function ProcessPromoteColumnsPendingWork(
1609
+ // Typed structurally rather than by name: schema-engine is only reachable here through a dynamic
1610
+ // import (it is a workspace package, not published), so a static type import is not available.
1611
+ // Deriving the payload from CompletePromotion's own signature keeps the two in step regardless.
1612
+ item: { PromotedColumns?: Parameters<IntegrationCustomColumnPromoter['CompletePromotion']>[0] },
1613
+ pendingWorkID: string,
1614
+ rsm: {
1615
+ CompletePendingWork(id: string, user: unknown): Promise<unknown>;
1616
+ FailPendingWork(id: string, message: string, user: unknown): Promise<unknown>;
1617
+ },
1618
+ systemUser: ConstructorParameters<typeof IntegrationCustomColumnPromoter>[0],
1619
+ ): Promise<void> {
1620
+ const promoted = item.PromotedColumns ?? [];
1621
+ if (promoted.length === 0) {
1622
+ // Nothing to do, but the row must not linger and be retried forever.
1623
+ await rsm.CompletePendingWork(pendingWorkID, systemUser);
1624
+ console.warn('[RSU] promote-columns pending work carried no PromotedColumns — nothing to complete.');
1625
+ return;
1626
+ }
1627
+ try {
1628
+ const promoter = new IntegrationCustomColumnPromoter(systemUser);
1629
+ const columns = await promoter.CompletePromotion(promoted);
1630
+ // Completed only after the work actually succeeded: a crash before this leaves the row
1631
+ // visible and re-processable, which is the whole point of the durable queue.
1632
+ await rsm.CompletePendingWork(pendingWorkID, systemUser);
1633
+ console.log(`[RSU] promote-columns: completed ${columns.length} column(s) across ${promoted.length} entity(ies).`);
1634
+ } catch (e) {
1635
+ const msg = e instanceof Error ? e.message : String(e);
1636
+ await rsm.FailPendingWork(pendingWorkID, `promote-columns completion failed: ${msg}`, systemUser);
1637
+ console.error(`[RSU] promote-columns completion failed: ${msg}`);
1638
+ }
1639
+ }
1640
+
1517
1641
  async function processRSUPendingWork(): Promise<void> {
1518
1642
  // Dynamic import — schema-engine is not yet published to npm, only exists as a workspace package
1519
1643
  const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
1520
1644
  const rsm = RuntimeSchemaManager.Instance;
1521
- const pendingItems = await rsm.ReadAndClearPendingWork();
1645
+
1646
+ // Get system user for server-side operations — needed to read the queue itself
1647
+ const systemUser = UserCache.Instance.Users.find(u => u.Type?.trim().toLowerCase() === 'owner') ?? UserCache.Instance.Users[0];
1648
+ if (!systemUser) {
1649
+ console.warn(`[RSU] No system user found — cannot process pending work`);
1650
+ return;
1651
+ }
1652
+
1653
+ // Rows older than this were left behind by an earlier process and are surfaced as stale.
1654
+ const pendingItems = await rsm.ReadPendingWork(systemUser, RSU_PENDING_WORK_STALE_MINUTES);
1522
1655
  if (pendingItems.length === 0) return;
1523
1656
 
1524
1657
  console.log(`[RSU] Processing ${pendingItems.length} pending work item(s) from pre-restart...`);
@@ -1526,18 +1659,26 @@ async function processRSUPendingWork(): Promise<void> {
1526
1659
  // Wait a moment for metadata to be fully loaded
1527
1660
  await new Promise(resolve => setTimeout(resolve, 3000));
1528
1661
 
1529
- for (const item of pendingItems) {
1662
+ for (const pending of pendingItems) {
1663
+ const pendingWorkID = pending.ID;
1664
+ const item = pending.Work;
1530
1665
  try {
1531
1666
  const md = new Metadata(); // global-provider-ok: server startup recovery — runs once before any per-request context exists
1532
- // Get system user for server-side operations
1533
- const systemUser = UserCache.Instance.Users.find(u => u.Type?.trim().toLowerCase() === 'owner') ?? UserCache.Instance.Users[0];
1534
- if (!systemUser) {
1535
- console.warn(`[RSU] No system user found, skipping pending work for ${item.CompanyIntegrationID}`);
1536
- continue;
1537
- }
1538
1667
 
1539
1668
  await Metadata.Provider.Refresh(); // global-provider-ok: server startup recovery — one-shot global cache refresh
1540
1669
 
1670
+ // Custom-column promotion registers its follow-up here rather than finishing inline, because
1671
+ // the restart is what loads the regenerated entity classes. Everything downstream of the
1672
+ // ADD COLUMN — the IntegrationObjectField rows, the field maps, the overflow spread — runs
1673
+ // now, with typed access to the columns that did not exist in the previous process.
1674
+ //
1675
+ // Absent WorkType means apply-objects: every row written before that field existed is one,
1676
+ // and the branch below must keep treating it that way.
1677
+ if (item.WorkType === 'promote-columns') {
1678
+ await ProcessPromoteColumnsPendingWork(item, pendingWorkID, rsm, systemUser);
1679
+ continue;
1680
+ }
1681
+
1541
1682
  // Resolve connector
1542
1683
  const rv = new RunView();
1543
1684
  const ciResult = await rv.RunView<MJCompanyIntegrationEntity>({
@@ -1547,6 +1688,7 @@ async function processRSUPendingWork(): Promise<void> {
1547
1688
  }, systemUser);
1548
1689
  const companyIntegration = ciResult.Results[0];
1549
1690
  if (!companyIntegration) {
1691
+ await rsm.FailPendingWork(pendingWorkID, `CompanyIntegration ${item.CompanyIntegrationID} not found`, systemUser);
1550
1692
  console.warn(`[RSU] CompanyIntegration ${item.CompanyIntegrationID} not found`);
1551
1693
  continue;
1552
1694
  }
@@ -1559,11 +1701,13 @@ async function processRSUPendingWork(): Promise<void> {
1559
1701
  }, systemUser);
1560
1702
  const integrationEntity = integrationResult.Results[0];
1561
1703
  if (!integrationEntity) {
1704
+ await rsm.FailPendingWork(pendingWorkID, `Integration entity for ${integrationName} not found`, systemUser);
1562
1705
  console.warn(`[RSU] Integration entity for ${integrationName} not found`);
1563
1706
  continue;
1564
1707
  }
1565
1708
  const connector = ConnectorFactory.Resolve(integrationEntity);
1566
1709
  if (!connector) {
1710
+ await rsm.FailPendingWork(pendingWorkID, `Connector for ${integrationName} not found`, systemUser);
1567
1711
  console.warn(`[RSU] Connector for ${integrationName} not found`);
1568
1712
  continue;
1569
1713
  }
@@ -1788,8 +1932,13 @@ async function processRSUPendingWork(): Promise<void> {
1788
1932
  console.warn(`[RSU] Schedule creation failed: ${schedErr}`);
1789
1933
  }
1790
1934
  }
1935
+
1936
+ // Only NOW is the work durably done — close the row out.
1937
+ await rsm.CompletePendingWork(pendingWorkID, systemUser);
1791
1938
  } catch (err) {
1792
- console.error(`[RSU] Failed to process pending work for ${item.CompanyIntegrationID}: ${err}`);
1939
+ const message = err instanceof Error ? err.message : String(err);
1940
+ console.error(`[RSU] Failed to process pending work for ${item.CompanyIntegrationID}: ${message}`);
1941
+ await rsm.FailPendingWork(pendingWorkID, message, systemUser);
1793
1942
  }
1794
1943
  }
1795
1944