@memberjunction/server 5.37.0 → 5.39.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 (121) hide show
  1. package/README.md +79 -12
  2. package/dist/agents/skip-sdk.d.ts.map +1 -1
  3. package/dist/agents/skip-sdk.js +16 -12
  4. package/dist/agents/skip-sdk.js.map +1 -1
  5. package/dist/apolloServer/index.d.ts +0 -8
  6. package/dist/apolloServer/index.d.ts.map +1 -1
  7. package/dist/apolloServer/index.js +61 -0
  8. package/dist/apolloServer/index.js.map +1 -1
  9. package/dist/config.d.ts +68 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +24 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/context.d.ts.map +1 -1
  14. package/dist/context.js +2 -21
  15. package/dist/context.js.map +1 -1
  16. package/dist/generated/generated.d.ts +172 -5
  17. package/dist/generated/generated.d.ts.map +1 -1
  18. package/dist/generated/generated.js +867 -14
  19. package/dist/generated/generated.js.map +1 -1
  20. package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
  21. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
  22. package/dist/generic/FireAndForgetHeartbeat.js +44 -0
  23. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +177 -16
  27. package/dist/index.js.map +1 -1
  28. package/dist/logging/NoLog.d.ts +50 -0
  29. package/dist/logging/NoLog.d.ts.map +1 -0
  30. package/dist/logging/NoLog.js +80 -0
  31. package/dist/logging/NoLog.js.map +1 -0
  32. package/dist/logging/bootAudit.d.ts +43 -0
  33. package/dist/logging/bootAudit.d.ts.map +1 -0
  34. package/dist/logging/bootAudit.js +83 -0
  35. package/dist/logging/bootAudit.js.map +1 -0
  36. package/dist/logging/boundaryLogPayload.d.ts +18 -0
  37. package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
  38. package/dist/logging/boundaryLogPayload.js +18 -0
  39. package/dist/logging/boundaryLogPayload.js.map +1 -0
  40. package/dist/logging/secretRedactor.d.ts +23 -0
  41. package/dist/logging/secretRedactor.d.ts.map +1 -0
  42. package/dist/logging/secretRedactor.js +53 -0
  43. package/dist/logging/secretRedactor.js.map +1 -0
  44. package/dist/logging/shortenForLog.d.ts +8 -0
  45. package/dist/logging/shortenForLog.d.ts.map +1 -0
  46. package/dist/logging/shortenForLog.js +21 -0
  47. package/dist/logging/shortenForLog.js.map +1 -0
  48. package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
  49. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
  50. package/dist/logging/variablesLoggingMiddleware.js +127 -0
  51. package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
  52. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  53. package/dist/resolvers/GetDataResolver.js +8 -4
  54. package/dist/resolvers/GetDataResolver.js.map +1 -1
  55. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
  56. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  57. package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
  58. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  59. package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
  60. package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
  61. package/dist/resolvers/IntegrationProgressResolver.js +196 -0
  62. package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
  63. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  64. package/dist/resolvers/MCPResolver.js +19 -14
  65. package/dist/resolvers/MCPResolver.js.map +1 -1
  66. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  67. package/dist/resolvers/QueryResolver.js +5 -4
  68. package/dist/resolvers/QueryResolver.js.map +1 -1
  69. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  70. package/dist/resolvers/QuerySystemUserResolver.js +6 -18
  71. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  72. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  73. package/dist/resolvers/RunAIAgentResolver.js +32 -64
  74. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  75. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  76. package/dist/resolvers/RunTestResolver.js +12 -2
  77. package/dist/resolvers/RunTestResolver.js.map +1 -1
  78. package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
  79. package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
  80. package/dist/resolvers/SearchEntitiesResolver.js +216 -0
  81. package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
  82. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  83. package/dist/resolvers/VectorizeEntityResolver.js +14 -1
  84. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  85. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  86. package/dist/services/ScheduledJobsService.js +14 -2
  87. package/dist/services/ScheduledJobsService.js.map +1 -1
  88. package/package.json +75 -74
  89. package/src/__tests__/NoLog.test.ts +76 -0
  90. package/src/__tests__/bootAudit.test.ts +188 -0
  91. package/src/__tests__/boundaryLogPayload.test.ts +31 -0
  92. package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
  93. package/src/__tests__/resolverBase.rls.test.ts +224 -0
  94. package/src/__tests__/secretRedactor.test.ts +163 -0
  95. package/src/__tests__/subscriptionRedaction.test.ts +217 -0
  96. package/src/agents/skip-sdk.ts +16 -13
  97. package/src/apolloServer/index.ts +58 -0
  98. package/src/config.ts +27 -0
  99. package/src/context.ts +2 -19
  100. package/src/generated/generated.ts +627 -15
  101. package/src/generic/FireAndForgetHeartbeat.ts +85 -0
  102. package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
  103. package/src/index.ts +199 -19
  104. package/src/logging/NoLog.ts +88 -0
  105. package/src/logging/bootAudit.ts +117 -0
  106. package/src/logging/boundaryLogPayload.ts +19 -0
  107. package/src/logging/secretRedactor.ts +82 -0
  108. package/src/logging/shortenForLog.ts +17 -0
  109. package/src/logging/variablesLoggingMiddleware.ts +191 -0
  110. package/src/resolvers/GetDataResolver.ts +9 -5
  111. package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
  112. package/src/resolvers/IntegrationProgressResolver.ts +220 -0
  113. package/src/resolvers/MCPResolver.ts +18 -14
  114. package/src/resolvers/QueryResolver.ts +7 -6
  115. package/src/resolvers/QuerySystemUserResolver.ts +11 -25
  116. package/src/resolvers/RunAIAgentResolver.ts +34 -71
  117. package/src/resolvers/RunTestResolver.ts +14 -2
  118. package/src/resolvers/SearchEntitiesResolver.ts +173 -0
  119. package/src/resolvers/VectorizeEntityResolver.ts +14 -1
  120. package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
  121. package/src/services/ScheduledJobsService.ts +15 -2
@@ -1,5 +1,5 @@
1
1
  import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType } from "type-graphql";
2
- import { CompositeKey, DatabaseProviderBase, LocalCacheManager, Metadata, RunView, UserInfo, LogError, IMetadataProvider } from "@memberjunction/core";
2
+ import { CompositeKey, DatabaseProviderBase, LocalCacheManager, Metadata, RunView, UserInfo, LogError, LogStatus, IMetadataProvider, TransactionGroupBase } from "@memberjunction/core";
3
3
  import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
4
4
  import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
5
5
  import {
@@ -27,7 +27,13 @@ import {
27
27
  IntegrationEngine,
28
28
  IntegrationSyncOptions,
29
29
  SourceSchemaInfo,
30
- IntegrationSchemaSync
30
+ IntegrationSchemaSync,
31
+ IntegrationConnectorCreationPipeline,
32
+ IntegrationActionGenerator
33
+ } from "@memberjunction/integration-engine";
34
+ import type {
35
+ IntegrationActionVerb,
36
+ GenerateIntegrationActionResult
31
37
  } from "@memberjunction/integration-engine";
32
38
  import { IntegrationEngineBase } from "@memberjunction/integration-engine-base";
33
39
  import {
@@ -41,6 +47,8 @@ import {
41
47
  } from "@memberjunction/integration-schema-builder";
42
48
  import { RuntimeSchemaManager, type RSUPipelineStep, type RSUPipelineInput } from "@memberjunction/schema-engine";
43
49
  import type { SchemaBuilderOutput } from "@memberjunction/integration-schema-builder";
50
+ import { IntegrationProgressReader } from "@memberjunction/integration-progress-artifacts";
51
+ import type { IntegrationRunSnapshot, IntegrationRunKind } from "@memberjunction/integration-progress-artifacts";
44
52
  import { ResolverBase } from "../generic/ResolverBase.js";
45
53
  import { AppContext } from "../types.js";
46
54
  import { RequireSystemUser } from "../directives/RequireSystemUser.js";
@@ -189,6 +197,7 @@ class DeleteConnectionOutput {
189
197
  @Field({ nullable: true }) EntityMapsDeleted?: number;
190
198
  @Field({ nullable: true }) FieldMapsDeleted?: number;
191
199
  @Field({ nullable: true }) SchedulesDeleted?: number;
200
+ @Field({ nullable: true }) CredentialDeleted?: boolean;
192
201
  }
193
202
 
194
203
  // ─── Schema Evolution Output ─────────────────────────────────────────────────
@@ -332,6 +341,71 @@ class ConnectionTestOutput {
332
341
  ServerVersion?: string;
333
342
  }
334
343
 
344
+ // ─── Refresh Connector Schema (Phase 0 v5.39.x) ─────────────────────────────
345
+ // Invokes IntegrationConnectorCreationPipeline.Run() which drives
346
+ // TestConnection → IntrospectSchema (parallel describe) →
347
+ // PersistDiscoveredSchema (overlay precedence: declared wins for semantic,
348
+ // discovered wins for technical) → SoftPKClassifier (4-tier cascade) and emits
349
+ // structured progress events the operator can grep in the MJAPI log file:
350
+ // "event":"discovery.object.added" with source: Declared | Discovered | Custom
351
+ // "event":"discovery.field.added" ditto
352
+ // "event":"pk.classifier.invoked" per object missing an explicit PK
353
+ // "event":"pk.classifier.result" the classifier's verdict + strategy + reason
354
+ // "event":"entity.generated" IO has a PK → eligible for MJ entity generation
355
+ // "event":"entity.skipped-no-pk" IO has NO PK → not eligible, deferred
356
+ //
357
+ // Per-run JSONL artifacts also land at:
358
+ // <cwd>/logs/integration-runs/<runID>/{manifest,progress,result}.json
359
+
360
+ @ObjectType()
361
+ class RefreshConnectorSchemaPKVerdictOutput {
362
+ @Field() ObjectName: string;
363
+ @Field() Confident: boolean;
364
+ @Field({ nullable: true }) Nominee?: string;
365
+ @Field() Confidence: number;
366
+ @Field() Strategy: string;
367
+ @Field() Reason: string;
368
+ }
369
+
370
+ @ObjectType()
371
+ class RefreshConnectorSchemaOutput {
372
+ @Field() Success: boolean;
373
+ @Field() Message: string;
374
+ @Field() RunID: string;
375
+ @Field({ nullable: true }) ObjectsCreated?: number;
376
+ @Field({ nullable: true }) ObjectsUpdated?: number;
377
+ @Field({ nullable: true }) FieldsCreated?: number;
378
+ @Field({ nullable: true }) FieldsUpdated?: number;
379
+ @Field(() => [RefreshConnectorSchemaPKVerdictOutput], { nullable: true }) PKVerdicts?: RefreshConnectorSchemaPKVerdictOutput[];
380
+ @Field(() => [String], { nullable: true }) UnresolvedObjects?: string[];
381
+ @Field({ nullable: true }) FailureMessage?: string;
382
+ }
383
+
384
+ // ─── Generate Integration Action (on-demand Integration-as-Actions) ─────────
385
+ // Generates + persists a strongly-typed Action (DriverClass='IntegrationActionExecutor')
386
+ // for one integration/object/verb (or all applicable verbs when verb is omitted) via
387
+ // the engine's IntegrationActionGenerator. Idempotent on the deterministic Action Name
388
+ // "<Integration> - <Verb> <DisplayName>": a matching Action is reused (AlreadyExisted=true)
389
+ // and its params/result codes reconciled rather than duplicated.
390
+
391
+ @ObjectType()
392
+ class IntegrationGenerateActionResult {
393
+ @Field() Success: boolean;
394
+ @Field({ nullable: true }) ActionID?: string;
395
+ @Field({ nullable: true }) ActionName?: string;
396
+ @Field() AlreadyExisted: boolean;
397
+ @Field({ nullable: true }) Verb?: string;
398
+ @Field({ nullable: true }) ObjectName?: string;
399
+ @Field() Message: string;
400
+ }
401
+
402
+ @ObjectType()
403
+ class IntegrationGenerateActionOutput {
404
+ @Field() Success: boolean;
405
+ @Field() Message: string;
406
+ @Field(() => [IntegrationGenerateActionResult], { nullable: true }) Results?: IntegrationGenerateActionResult[];
407
+ }
408
+
335
409
  // --- Preview Data Types ---
336
410
 
337
411
  @ObjectType()
@@ -462,6 +536,17 @@ class CreateConnectionInput {
462
536
  @Field({ nullable: true }) Configuration?: string;
463
537
  }
464
538
 
539
+ @ObjectType()
540
+ class CreateConnectionPipelineSummary {
541
+ @Field() RunID: string;
542
+ @Field() ObjectsCreated: number;
543
+ @Field() ObjectsUpdated: number;
544
+ @Field() FieldsCreated: number;
545
+ @Field() FieldsUpdated: number;
546
+ @Field(() => [String]) UnresolvedObjects: string[];
547
+ @Field(() => [RefreshConnectorSchemaPKVerdictOutput], { nullable: true }) PKVerdicts?: RefreshConnectorSchemaPKVerdictOutput[];
548
+ }
549
+
465
550
  @ObjectType()
466
551
  class CreateConnectionOutput {
467
552
  @Field() Success: boolean;
@@ -470,6 +555,13 @@ class CreateConnectionOutput {
470
555
  @Field({ nullable: true }) CredentialID?: string;
471
556
  @Field({ nullable: true }) ConnectionTestSuccess?: boolean;
472
557
  @Field({ nullable: true }) ConnectionTestMessage?: string;
558
+ /**
559
+ * Schema-refresh pipeline result. Populated when the resolver auto-runs
560
+ * IntegrationConnectorCreationPipeline after a successful TestConnection.
561
+ * Use to drive the wizard's next step (show user how many IOs were live-
562
+ * discovered, what's still PK-less, etc).
563
+ */
564
+ @Field(() => CreateConnectionPipelineSummary, { nullable: true }) SchemaRefresh?: CreateConnectionPipelineSummary;
473
565
  }
474
566
 
475
567
  @ObjectType()
@@ -493,6 +585,8 @@ class EntityMapInput {
493
585
  @Field({ nullable: true }) EntityID?: string;
494
586
  @Field({ nullable: true, defaultValue: 'Pull' }) SyncDirection?: string;
495
587
  @Field({ nullable: true, defaultValue: 0 }) Priority?: number;
588
+ /** Per-map engine config JSON (e.g. {"partitionReconcile":true,"partitionCount":256}). GQL-set so it's the source of truth. */
589
+ @Field({ nullable: true }) Configuration?: string;
496
590
  @Field(() => [FieldMapInput], { nullable: true }) FieldMaps?: FieldMapInput[];
497
591
  }
498
592
 
@@ -570,6 +664,8 @@ class EntityMapUpdateInput {
570
664
  @Field({ nullable: true }) SyncDirection?: string;
571
665
  @Field({ nullable: true }) Priority?: number;
572
666
  @Field({ nullable: true }) Status?: string;
667
+ /** Per-map engine config JSON (e.g. {"partitionReconcile":true}). GQL is the source of truth. */
668
+ @Field({ nullable: true }) Configuration?: string;
573
669
  }
574
670
 
575
671
  @ObjectType()
@@ -581,6 +677,8 @@ class EntityMapSummaryOutput {
581
677
  @Field({ nullable: true }) SyncDirection?: string;
582
678
  @Field({ nullable: true }) Priority?: number;
583
679
  @Field({ nullable: true }) Status?: string;
680
+ /** Per-map engine config JSON (partitionReconcile, etc.) so callers can read the source of truth. */
681
+ @Field({ nullable: true }) Configuration?: string;
584
682
  }
585
683
 
586
684
  @ObjectType()
@@ -657,6 +755,87 @@ class OperationProgressOutput {
657
755
  @Field({ nullable: true }) StartedAt?: string;
658
756
  }
659
757
 
758
+ // ── STRUCTURED RUN ARTIFACTS (durable JSONL progress streams) ─────────
759
+ // These expose the IntegrationProgressReader over GraphQL so a tenant can ask,
760
+ // at any time, "what exactly happened (or is happening) on this run?" — backed
761
+ // by the append-only <cwd>/logs/integration-runs/<runID>/progress.jsonl files
762
+ // that survive an MJAPI restart and grow as the run progresses. Poll
763
+ // IntegrationTailRunEvents(runID, sinceSeq) to follow a live run incrementally.
764
+
765
+ @ObjectType()
766
+ class IntegrationRunCountsOutput {
767
+ @Field({ nullable: true }) Processed?: number;
768
+ @Field({ nullable: true }) Succeeded?: number;
769
+ @Field({ nullable: true }) Failed?: number;
770
+ @Field({ nullable: true }) Skipped?: number;
771
+ @Field({ nullable: true }) TotalKnown?: number;
772
+ }
773
+
774
+ @ObjectType()
775
+ class IntegrationRunSummaryArtifactOutput {
776
+ @Field() RunID: string;
777
+ @Field() RunKind: string;
778
+ @Field({ nullable: true }) IntegrationID?: string;
779
+ @Field({ nullable: true }) CompanyIntegrationID?: string;
780
+ @Field({ nullable: true }) ObjectName?: string;
781
+ @Field({ nullable: true }) TriggerType?: string;
782
+ @Field() StartedAt: string;
783
+ @Field() IsInFlight: boolean;
784
+ @Field() EventCount: number;
785
+ @Field({ nullable: true }) Success?: boolean;
786
+ @Field({ nullable: true }) ExitReason?: string;
787
+ @Field({ nullable: true }) CompletedAt?: string;
788
+ @Field({ nullable: true }) DurationMs?: number;
789
+ @Field({ nullable: true }) LatestEventType?: string;
790
+ @Field({ nullable: true }) LatestMessage?: string;
791
+ @Field(() => IntegrationRunCountsOutput, { nullable: true }) Counts?: IntegrationRunCountsOutput;
792
+ /** Count of non-fatal warnings surfaced during the run (e.g. a second-layer object that found zero parents). Warnings never fail the run, but they make silent-empty conditions visible. */
793
+ @Field({ nullable: true }) WarningCount?: number;
794
+ /** Human-readable warnings ("[CODE] stage: message") so a tenant can see exactly what was flagged without paging the full event stream. */
795
+ @Field(() => [String], { nullable: true }) Warnings?: string[];
796
+ }
797
+
798
+ @ObjectType()
799
+ class IntegrationRunEventOutput {
800
+ @Field() Ts: string;
801
+ @Field() Seq: number;
802
+ @Field() EventType: string;
803
+ @Field({ nullable: true }) Level?: string;
804
+ @Field({ nullable: true }) Stage?: string;
805
+ @Field({ nullable: true }) Message?: string;
806
+ @Field(() => IntegrationRunCountsOutput, { nullable: true }) Counts?: IntegrationRunCountsOutput;
807
+ /** Subsystem-specific payload, JSON-encoded (clients JSON.parse). */
808
+ @Field({ nullable: true }) DataJSON?: string;
809
+ /** Resumable-state payload on checkpoint events, JSON-encoded. */
810
+ @Field({ nullable: true }) ResumableStateJSON?: string;
811
+ }
812
+
813
+ @ObjectType()
814
+ class IntegrationListRunsOutput {
815
+ @Field() Success: boolean;
816
+ @Field() Message: string;
817
+ @Field(() => [IntegrationRunSummaryArtifactOutput], { nullable: true }) Runs?: IntegrationRunSummaryArtifactOutput[];
818
+ }
819
+
820
+ @ObjectType()
821
+ class IntegrationRunDetailOutput {
822
+ @Field() Success: boolean;
823
+ @Field() Message: string;
824
+ @Field(() => IntegrationRunSummaryArtifactOutput, { nullable: true }) Run?: IntegrationRunSummaryArtifactOutput;
825
+ @Field(() => [String], { nullable: true }) Errors?: string[];
826
+ }
827
+
828
+ @ObjectType()
829
+ class IntegrationRunEventsOutput {
830
+ @Field() Success: boolean;
831
+ @Field() Message: string;
832
+ @Field(() => [IntegrationRunEventOutput], { nullable: true }) Events?: IntegrationRunEventOutput[];
833
+ /** Highest sequence returned — pass back as sinceSeq to poll for more. */
834
+ @Field() LatestSeq: number;
835
+ /** True while the run is still active — keep polling until false. */
836
+ @Field() IsInFlight: boolean;
837
+ }
838
+
660
839
  // Sync progress is now tracked inside IntegrationEngine itself via IntegrationEngine.GetSyncProgress()
661
840
 
662
841
  @ObjectType()
@@ -696,6 +875,13 @@ function isValidEntityMapStatus(value: string): value is EntityMapStatus {
696
875
  return (VALID_ENTITY_MAP_STATUSES as readonly string[]).includes(value);
697
876
  }
698
877
 
878
+ // IntegrationActionVerb is the engine-defined union ('Get'|'Create'|'Update'|'Delete'|'Search'|'List').
879
+ const VALID_INTEGRATION_ACTION_VERBS = ['Get', 'Create', 'Update', 'Delete', 'Search', 'List'] as const;
880
+
881
+ function isValidIntegrationActionVerb(value: string): value is IntegrationActionVerb {
882
+ return (VALID_INTEGRATION_ACTION_VERBS as readonly string[]).includes(value);
883
+ }
884
+
699
885
  // ─── List Source Objects (Full-Catalog Picker) ──────────────────────────────
700
886
  // Returns every object the source system exposes (e.g. all ~1,800 Salesforce
701
887
  // sobjects), merged with any existing IntegrationObject metadata so the UI
@@ -978,6 +1164,172 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
978
1164
  }
979
1165
  }
980
1166
 
1167
+ /**
1168
+ * Refreshes the connector's IntegrationObject + IntegrationObjectField
1169
+ * catalog by running the Phase 0 v5.39.x IntegrationConnectorCreationPipeline.
1170
+ * The pipeline:
1171
+ * 1. TestConnection — validates credentials before any heavy work.
1172
+ * 2. IntrospectSchema — parallel describe across all objects.
1173
+ * 3. PersistDiscoveredSchema — overlay-aware upsert (declared wins for
1174
+ * semantic, discovered wins for technical attributes), populates
1175
+ * MetadataSource and per-attribute AttributeWinners.
1176
+ * 4. PKClassify — SoftPKClassifier 4-tier cascade for any IO
1177
+ * that still lacks an explicit PK marker.
1178
+ *
1179
+ * Structured progress events ride the IntegrationProgressEmitter and land
1180
+ * both on stdout (visible in the MJAPI log file) and in a per-run
1181
+ * `<cwd>/logs/integration-runs/<runID>/progress.jsonl` artifact.
1182
+ */
1183
+ @Mutation(() => RefreshConnectorSchemaOutput)
1184
+ async IntegrationRefreshConnectorSchema(
1185
+ @Arg("companyIntegrationID") companyIntegrationID: string,
1186
+ @Arg("universalPKConvention", { nullable: true, description: "Optional vendor-wide PK convention hint (e.g. 'id' for HubSpot)" }) universalPKConvention: string | undefined,
1187
+ @Ctx() ctx: AppContext
1188
+ ): Promise<RefreshConnectorSchemaOutput> {
1189
+ try {
1190
+ const user = this.getAuthenticatedUser(ctx);
1191
+ const provider = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
1192
+ const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user, provider);
1193
+
1194
+ const pipeline = new IntegrationConnectorCreationPipeline();
1195
+ // Cast through unknown — duplicate package type declarations
1196
+ // between integration-engine's resolved core-entities and this
1197
+ // resolver's resolved core-entities (same shape, different
1198
+ // identities at type level).
1199
+ const runOpts = {
1200
+ Connector: connector,
1201
+ CompanyIntegration: companyIntegration,
1202
+ ContextUser: user,
1203
+ Provider: provider,
1204
+ UniversalPKConvention: universalPKConvention || undefined,
1205
+ ConsoleMirror: true,
1206
+ TriggerType: 'Manual' as const,
1207
+ };
1208
+ const result = await pipeline.Run(runOpts as unknown as Parameters<typeof pipeline.Run>[0]);
1209
+
1210
+ // Refresh the metadata cache so subsequent reads see the new IO/IOF
1211
+ // rows the pipeline just wrote. Without this the engine returns the
1212
+ // pre-pipeline snapshot until the next process bootstrap.
1213
+ const md = provider ?? new Metadata();
1214
+ await md.Refresh();
1215
+ await IntegrationEngine.Instance.Config(true, user, provider);
1216
+
1217
+ return {
1218
+ Success: result.Success,
1219
+ Message: result.Success
1220
+ ? `Refresh complete: ${result.PersistResult?.ObjectsCreated ?? 0} created, ${result.PersistResult?.ObjectsUpdated ?? 0} updated, ${result.UnresolvedObjects.length} IOs still PK-less (deferred to additionalSchemaInfo authoring)`
1221
+ : `Refresh failed: ${result.FailureMessage ?? 'unknown error'}`,
1222
+ RunID: result.RunID,
1223
+ ObjectsCreated: result.PersistResult?.ObjectsCreated,
1224
+ ObjectsUpdated: result.PersistResult?.ObjectsUpdated,
1225
+ FieldsCreated: result.PersistResult?.FieldsCreated,
1226
+ FieldsUpdated: result.PersistResult?.FieldsUpdated,
1227
+ PKVerdicts: result.PKVerdicts.map(v => ({
1228
+ ObjectName: v.ObjectName,
1229
+ Confident: v.Confident,
1230
+ Nominee: v.Nominee,
1231
+ Confidence: v.Confidence,
1232
+ Strategy: v.Strategy,
1233
+ Reason: v.Reason,
1234
+ })),
1235
+ UnresolvedObjects: result.UnresolvedObjects,
1236
+ FailureMessage: result.FailureMessage,
1237
+ };
1238
+ } catch (e) {
1239
+ LogError(`IntegrationRefreshConnectorSchema error: ${this.formatError(e)}`);
1240
+ return {
1241
+ Success: false,
1242
+ Message: `Error: ${this.formatError(e)}`,
1243
+ RunID: 'error',
1244
+ };
1245
+ }
1246
+ }
1247
+
1248
+ /**
1249
+ * Generates + persists strongly-typed Action metadata on demand for an
1250
+ * integration object. When `verb` is supplied, a single Action is generated
1251
+ * for that (integration, object, verb); when omitted, all applicable verbs
1252
+ * for the object are generated (Get/Search/List always, Create/Update/Delete
1253
+ * only when the object supports writes).
1254
+ *
1255
+ * Idempotent: each generated Action is keyed on the deterministic Name
1256
+ * "<Integration> - <Verb> <DisplayName>". An existing Action with that Name is
1257
+ * reused (AlreadyExisted=true) and its params/result codes reconciled rather
1258
+ * than duplicated. Generated Actions use DriverClass='IntegrationActionExecutor'
1259
+ * and carry routing info ({IntegrationName, ObjectName, Verb}) in Action.Config_;
1260
+ * the IntegrationActionExecutor (CoreActions) is the single runtime dispatcher.
1261
+ */
1262
+ @Mutation(() => IntegrationGenerateActionOutput)
1263
+ async IntegrationGenerateAction(
1264
+ @Arg("integrationName") integrationName: string,
1265
+ @Arg("objectName") objectName: string,
1266
+ @Arg("verb", { nullable: true, description: "Optional CRUD verb (Get|Create|Update|Delete|Search|List). Omit to generate all applicable verbs for the object." }) verb: string | undefined,
1267
+ @Ctx() ctx: AppContext
1268
+ ): Promise<IntegrationGenerateActionOutput> {
1269
+ try {
1270
+ const user = this.getAuthenticatedUser(ctx);
1271
+ const provider = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
1272
+
1273
+ const generator = new IntegrationActionGenerator();
1274
+ const results = await this.runActionGenerator(generator, integrationName, objectName, verb, user, provider);
1275
+
1276
+ return this.mapGenerateActionResults(results);
1277
+ } catch (e) {
1278
+ LogError(`IntegrationGenerateAction error: ${this.formatError(e)}`);
1279
+ return { Success: false, Message: `Error: ${this.formatError(e)}` };
1280
+ }
1281
+ }
1282
+
1283
+ /**
1284
+ * Drives the engine's IntegrationActionGenerator: one verb when `verb` is a
1285
+ * valid value, all applicable verbs when it is omitted. Casts user/provider
1286
+ * through `unknown` at the boundary — integration-engine resolves its own copy
1287
+ * of core/core-entities, so the types are structurally identical but nominally
1288
+ * distinct (same bridge pattern used by IntegrationRefreshConnectorSchema).
1289
+ */
1290
+ private async runActionGenerator(
1291
+ generator: IntegrationActionGenerator,
1292
+ integrationName: string,
1293
+ objectName: string,
1294
+ verb: string | undefined,
1295
+ user: UserInfo,
1296
+ provider: IMetadataProvider
1297
+ ): Promise<GenerateIntegrationActionResult[]> {
1298
+ const u = user as unknown as Parameters<IntegrationActionGenerator['GenerateAction']>[3];
1299
+ const p = provider as unknown as Parameters<IntegrationActionGenerator['GenerateAction']>[4];
1300
+
1301
+ if (verb != null && verb.length > 0) {
1302
+ if (!isValidIntegrationActionVerb(verb)) {
1303
+ throw new Error(`Invalid verb "${verb}". Must be one of: ${VALID_INTEGRATION_ACTION_VERBS.join(', ')}`);
1304
+ }
1305
+ const single = await generator.GenerateAction(integrationName, objectName, verb, u, p);
1306
+ return [single];
1307
+ }
1308
+ return generator.GenerateActionsForObject(integrationName, objectName, u, p);
1309
+ }
1310
+
1311
+ /** Maps engine GenerateIntegrationActionResult[] into the GraphQL output shape. */
1312
+ private mapGenerateActionResults(results: GenerateIntegrationActionResult[]): IntegrationGenerateActionOutput {
1313
+ const mapped: IntegrationGenerateActionResult[] = results.map(r => ({
1314
+ Success: r.Success,
1315
+ ActionID: r.ActionID,
1316
+ ActionName: r.ActionName,
1317
+ AlreadyExisted: r.AlreadyExisted,
1318
+ Verb: r.Verb,
1319
+ ObjectName: r.ObjectName,
1320
+ Message: r.Message,
1321
+ }));
1322
+
1323
+ const successCount = mapped.filter(r => r.Success).length;
1324
+ const overallSuccess = mapped.length > 0 && mapped.every(r => r.Success);
1325
+
1326
+ return {
1327
+ Success: overallSuccess,
1328
+ Message: `Generated ${successCount}/${mapped.length} action(s)`,
1329
+ Results: mapped,
1330
+ };
1331
+ }
1332
+
981
1333
  /**
982
1334
  * Returns the connector's default configuration for quick setup.
983
1335
  * Not all connectors provide defaults — returns Success: false if unavailable.
@@ -1593,6 +1945,69 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1593
1945
  return sysUser;
1594
1946
  }
1595
1947
 
1948
+ /**
1949
+ * Per-company read-authorization check for run artifacts.
1950
+ *
1951
+ * Reuses MJ's established row-level-security pattern: a `RunView` on
1952
+ * `MJ: Company Integrations` executed under the *calling user's* context.
1953
+ * The data provider applies the same read-permission / RLS filtering it
1954
+ * applies to every other user-context read, so the record only comes back
1955
+ * when the caller is genuinely authorized to see that CompanyIntegration.
1956
+ * No row returned ⇒ either the ID doesn't exist or the caller has no rights
1957
+ * to it — in both cases we treat the caller as unauthorized.
1958
+ *
1959
+ * Results are memoized per call via the supplied `cache` map so a list of
1960
+ * many runs sharing a CompanyIntegrationID incurs at most one lookup each.
1961
+ */
1962
+ private async userCanReadCompanyIntegration(
1963
+ companyIntegrationID: string,
1964
+ user: UserInfo,
1965
+ cache: Map<string, boolean>
1966
+ ): Promise<boolean> {
1967
+ const cached = cache.get(companyIntegrationID);
1968
+ if (cached !== undefined) {
1969
+ return cached;
1970
+ }
1971
+
1972
+ const rv = new RunView();
1973
+ const result = await rv.RunView<MJCompanyIntegrationEntity>({
1974
+ EntityName: 'MJ: Company Integrations',
1975
+ ExtraFilter: `ID='${companyIntegrationID}'`,
1976
+ MaxRows: 1,
1977
+ ResultType: 'simple',
1978
+ Fields: ['ID']
1979
+ }, user);
1980
+
1981
+ const authorized = result.Success && result.Results.length > 0;
1982
+ cache.set(companyIntegrationID, authorized);
1983
+ return authorized;
1984
+ }
1985
+
1986
+ /**
1987
+ * Authorizes the caller for a single run artifact based on the run's
1988
+ * manifest CompanyIntegrationID. Returns true when the run is tenant-scoped
1989
+ * and the caller is authorized for that CompanyIntegration. Returns false
1990
+ * for tenant-scoped runs the caller may not read, OR for runs with no
1991
+ * CompanyIntegrationID (non-tenant-scoped artifacts are not exposed through
1992
+ * these per-company endpoints).
1993
+ */
1994
+ private async userCanReadRunArtifact(
1995
+ snap: IntegrationRunSnapshot,
1996
+ user: UserInfo,
1997
+ cache: Map<string, boolean>
1998
+ ): Promise<boolean> {
1999
+ const ciID = snap.manifest.companyIntegrationID;
2000
+ if (!ciID) {
2001
+ return false;
2002
+ }
2003
+ return this.userCanReadCompanyIntegration(ciID, user, cache);
2004
+ }
2005
+
2006
+ /** Standard authorization-failure message for run-artifact endpoints. */
2007
+ private notAuthorizedForCompanyIntegrationMessage(companyIntegrationID: string): string {
2008
+ return `Not authorized to access runs for CompanyIntegration '${companyIntegrationID}'`;
2009
+ }
2010
+
1596
2011
  /**
1597
2012
  * Loads the CompanyIntegration + its parent Integration, then resolves the
1598
2013
  * appropriate connector via ConnectorFactory.
@@ -1674,6 +2089,140 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1674
2089
  return testFn(companyIntegration, user);
1675
2090
  }
1676
2091
 
2092
+ /**
2093
+ * Runs IntegrationConnectorCreationPipeline + refreshes the metadata cache.
2094
+ * Shared by IntegrationCreateConnection's auto-refresh path and the
2095
+ * standalone IntegrationRefreshConnectorSchema mutation.
2096
+ */
2097
+ /**
2098
+ * Reconstructs a `SourceSchemaInfo` from already-persisted IntegrationObject
2099
+ * and IntegrationObjectField rows held by `IntegrationEngineBase`'s
2100
+ * in-memory cache.
2101
+ *
2102
+ * Why this exists: after the Phase 0 v5.39.x `MJCompanyIntegrationEntityServer`
2103
+ * hook fires the pipeline on `IsActive false→true`, the IO/IOF rows are
2104
+ * already fresh. The legacy `IntegrationApplyAllBatch` / `ApplyAll` /
2105
+ * `ApplySchema` resolvers then called `connector.IntrospectSchema()` AGAIN
2106
+ * to feed `SchemaBuilder`, which produced a second full vendor-API
2107
+ * roundtrip (HubSpot 130 objects × 60+ DiscoverFields probes, ~100s
2108
+ * wasted on the user's HubSpot run). Using the persisted rows skips
2109
+ * that round-trip entirely.
2110
+ *
2111
+ * Caveats:
2112
+ * - The persisted `Type` is the MJ canonical type (string / int / datetime / …),
2113
+ * NOT the original vendor source type. `TypeMapper` in SchemaBuilder
2114
+ * accepts canonical types so DDL generation still works; if a connector
2115
+ * has unusual nuances around its source-type strings, fall back to live
2116
+ * introspect (the caller can pass `forceLive: true`).
2117
+ * - PrimaryKeyFields are recomputed from the IOF rows where
2118
+ * `IsPrimaryKey=true`.
2119
+ * - Foreign-key relationships are reconstructed from
2120
+ * `RelatedIntegrationObjectID` lookups against the same cache.
2121
+ */
2122
+ private buildSourceSchemaFromPersistedRows(
2123
+ integrationID: string,
2124
+ requestedNames?: string[],
2125
+ ): SourceSchemaInfo {
2126
+ const engine = IntegrationEngineBase.Instance;
2127
+ const ios = engine.GetIntegrationObjectsByIntegrationID(integrationID);
2128
+ const filter = requestedNames && requestedNames.length > 0
2129
+ ? new Set(requestedNames.map(n => n.toLowerCase()))
2130
+ : null;
2131
+
2132
+ // Cache (id → name) for FK relationship reconstruction. Same-integration
2133
+ // only — cross-integration relationships are not modeled in the slot table.
2134
+ const ioByID = new Map<string, string>();
2135
+ for (const io of ios) ioByID.set(io.ID, io.Name);
2136
+
2137
+ const result: SourceSchemaInfo = { Objects: [] };
2138
+ for (const io of ios) {
2139
+ if (filter && !filter.has(io.Name.toLowerCase())) continue;
2140
+ const iofs = engine.GetIntegrationObjectFields(io.ID);
2141
+
2142
+ const fields = iofs.map(iof => {
2143
+ const targetIOName = iof.RelatedIntegrationObjectID
2144
+ ? ioByID.get(iof.RelatedIntegrationObjectID) ?? null
2145
+ : null;
2146
+ return {
2147
+ Name: iof.Name,
2148
+ Label: iof.DisplayName ?? iof.Name,
2149
+ Description: iof.Description ?? undefined,
2150
+ SourceType: iof.Type ?? 'string',
2151
+ IsRequired: iof.IsRequired ?? false,
2152
+ AllowsNull: iof.AllowsNull ?? undefined,
2153
+ MaxLength: iof.Length ?? null,
2154
+ Precision: iof.Precision ?? null,
2155
+ Scale: iof.Scale ?? null,
2156
+ DefaultValue: iof.DefaultValue ?? null,
2157
+ IsPrimaryKey: iof.IsPrimaryKey ?? false,
2158
+ IsUniqueKey: iof.IsUniqueKey ?? false,
2159
+ IsReadOnly: iof.IsReadOnly ?? false,
2160
+ IsForeignKey: !!iof.RelatedIntegrationObjectID,
2161
+ ForeignKeyTarget: targetIOName,
2162
+ };
2163
+ });
2164
+
2165
+ result.Objects.push({
2166
+ ExternalName: io.Name,
2167
+ ExternalLabel: io.DisplayName ?? io.Name,
2168
+ Description: io.Description ?? undefined,
2169
+ Fields: fields,
2170
+ PrimaryKeyFields: fields.filter(f => f.IsPrimaryKey).map(f => f.Name),
2171
+ Relationships: fields
2172
+ .filter(f => f.IsForeignKey && f.ForeignKeyTarget)
2173
+ .map(f => ({
2174
+ FieldName: f.Name,
2175
+ TargetObject: f.ForeignKeyTarget!,
2176
+ TargetField: 'ID',
2177
+ })),
2178
+ IncrementalWatermarkField: io.IncrementalWatermarkField ?? undefined,
2179
+ });
2180
+ }
2181
+ return result;
2182
+ }
2183
+
2184
+ private async runSchemaRefreshPipeline(
2185
+ companyIntegrationID: string,
2186
+ user: UserInfo,
2187
+ provider: IMetadataProvider,
2188
+ universalPKConvention?: string,
2189
+ ): Promise<CreateConnectionPipelineSummary> {
2190
+ const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user, provider);
2191
+ const pipeline = new IntegrationConnectorCreationPipeline();
2192
+ const runOpts = {
2193
+ Connector: connector,
2194
+ CompanyIntegration: companyIntegration,
2195
+ ContextUser: user,
2196
+ Provider: provider,
2197
+ UniversalPKConvention: universalPKConvention || undefined,
2198
+ ConsoleMirror: true,
2199
+ TriggerType: 'Manual' as const,
2200
+ };
2201
+ const result = await pipeline.Run(runOpts as unknown as Parameters<typeof pipeline.Run>[0]);
2202
+
2203
+ // Refresh in-memory caches so downstream queries (object picker,
2204
+ // ApplyAll, etc.) see the just-written IO/IOF rows.
2205
+ await (provider ?? new Metadata()).Refresh();
2206
+ await IntegrationEngine.Instance.Config(true, user, provider);
2207
+
2208
+ return {
2209
+ RunID: result.RunID,
2210
+ ObjectsCreated: result.PersistResult?.ObjectsCreated ?? 0,
2211
+ ObjectsUpdated: result.PersistResult?.ObjectsUpdated ?? 0,
2212
+ FieldsCreated: result.PersistResult?.FieldsCreated ?? 0,
2213
+ FieldsUpdated: result.PersistResult?.FieldsUpdated ?? 0,
2214
+ UnresolvedObjects: result.UnresolvedObjects,
2215
+ PKVerdicts: result.PKVerdicts.map(v => ({
2216
+ ObjectName: v.ObjectName,
2217
+ Confident: v.Confident,
2218
+ Nominee: v.Nominee,
2219
+ Confidence: v.Confidence,
2220
+ Strategy: v.Strategy,
2221
+ Reason: v.Reason,
2222
+ })),
2223
+ };
2224
+ }
2225
+
1677
2226
  /**
1678
2227
  * Rolls back a freshly created connection by deleting both the CompanyIntegration and Credential records.
1679
2228
  */
@@ -1685,6 +2234,49 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1685
2234
  try { await credential.Delete(); } catch (e) { LogError(`Rollback: failed to delete Credential: ${this.formatError(e)}`); }
1686
2235
  }
1687
2236
 
2237
+ /**
2238
+ * Cascades the deletion of a CompanyIntegration's linked Credential as part of the
2239
+ * supplied TransactionGroup, so the encrypted-credential row is removed atomically
2240
+ * with the rest of the connection cascade (rolls back together on tg failure).
2241
+ *
2242
+ * Safety:
2243
+ * - No-op when {@link credentialID} is null/empty.
2244
+ * - Skips the delete when ANOTHER CompanyIntegration still references the same
2245
+ * CredentialID (shared credential) — only the sole referencer may delete it.
2246
+ *
2247
+ * @returns true if the credential was attached to the tg for deletion; false otherwise.
2248
+ */
2249
+ private async cascadeDeleteCredential(
2250
+ credentialID: string | null | undefined,
2251
+ companyIntegrationID: string,
2252
+ tg: TransactionGroupBase,
2253
+ rv: RunView,
2254
+ provider: IMetadataProvider,
2255
+ user: UserInfo
2256
+ ): Promise<boolean> {
2257
+ if (!credentialID) return false; // no linked credential — nothing to do
2258
+
2259
+ // Shared-credential safety: do not delete if any OTHER CompanyIntegration uses it.
2260
+ const sharedResult = await rv.RunView<MJCompanyIntegrationEntity>({
2261
+ EntityName: 'MJ: Company Integrations',
2262
+ ExtraFilter: `CredentialID='${credentialID}' AND ID<>'${companyIntegrationID}'`,
2263
+ ResultType: 'simple',
2264
+ Fields: ['ID']
2265
+ }, user);
2266
+ if (sharedResult.Success && sharedResult.Results.length > 0) {
2267
+ LogStatus(`IntegrationDeleteConnection: credential ${credentialID} is shared by ${sharedResult.Results.length} other connection(s); leaving it in place`);
2268
+ return false;
2269
+ }
2270
+
2271
+ const credential = await provider.GetEntityObject<MJCredentialEntity>('MJ: Credentials', user);
2272
+ const loaded = await credential.InnerLoad(CompositeKey.FromID(credentialID));
2273
+ if (!loaded) return false; // already gone — treat as nothing to cascade
2274
+
2275
+ credential.TransactionGroup = tg;
2276
+ await credential.Delete();
2277
+ return true;
2278
+ }
2279
+
1688
2280
  /**
1689
2281
  * Snapshots the current credential Values for a given credential ID so they can be restored on rollback.
1690
2282
  */
@@ -1799,6 +2391,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1799
2391
  async IntegrationCreateConnection(
1800
2392
  @Arg("input") input: CreateConnectionInput,
1801
2393
  @Arg("testConnection", () => Boolean, { defaultValue: false }) testConnection: boolean,
2394
+ @Arg("runSchemaRefresh", () => Boolean, { defaultValue: true, description: "When true (default) and TestConnection succeeds, automatically runs IntegrationConnectorCreationPipeline (live introspect → persist Declared/Discovered/Custom → SoftPKClassifier). The intermittent server-side work the wizard's Forward step represents." }) runSchemaRefresh: boolean,
2395
+ @Arg("universalPKConvention", { nullable: true, description: "Optional vendor-wide PK hint (e.g. 'id' for HubSpot). Improves SoftPKClassifier convergence." }) universalPKConvention: string | undefined,
1802
2396
  @Ctx() ctx: AppContext
1803
2397
  ): Promise<CreateConnectionOutput> {
1804
2398
  try {
@@ -1838,6 +2432,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1838
2432
  }
1839
2433
 
1840
2434
  // 3. Optionally test the connection; rollback on failure
2435
+ let testPassed: boolean = !testConnection; // if no test asked, treat as "passed" so the refresh below still runs
2436
+ let testMessage = '';
1841
2437
  if (testConnection) {
1842
2438
  const testResult = await this.testConnectionForCI(ci.ID, user, md);
1843
2439
  if (!testResult.Success) {
@@ -1849,13 +2445,39 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1849
2445
  ConnectionTestMessage: testResult.Message
1850
2446
  };
1851
2447
  }
2448
+ testPassed = true;
2449
+ testMessage = testResult.Message;
2450
+ }
2451
+
2452
+ // 4. Auto-run schema refresh pipeline (intermittent server-side period).
2453
+ // Fires whenever runSchemaRefresh=true, regardless of whether the
2454
+ // caller also asked for a test. The wizard may have tested separately
2455
+ // and just be hitting Create to save.
2456
+ let schemaRefreshSummary: CreateConnectionPipelineSummary | undefined;
2457
+ if (runSchemaRefresh) {
2458
+ try {
2459
+ const refreshResult = await this.runSchemaRefreshPipeline(
2460
+ ci.ID, user, md, universalPKConvention
2461
+ );
2462
+ schemaRefreshSummary = refreshResult;
2463
+ } catch (refreshErr) {
2464
+ // Refresh failure does NOT roll back the connection —
2465
+ // user can re-run via IntegrationRefreshConnectorSchema.
2466
+ LogError(`IntegrationCreateConnection: pipeline error — ${refreshErr}`);
2467
+ }
2468
+ }
2469
+
2470
+ if (testConnection || schemaRefreshSummary) {
1852
2471
  return {
1853
2472
  Success: true,
1854
- Message: 'Connection created and test passed',
2473
+ Message: schemaRefreshSummary
2474
+ ? `Connection created${testConnection ? ', test passed' : ''}, schema refresh: ${schemaRefreshSummary.ObjectsCreated} created, ${schemaRefreshSummary.ObjectsUpdated} updated, ${schemaRefreshSummary.UnresolvedObjects.length} PK-unresolved`
2475
+ : 'Connection created and test passed',
1855
2476
  CompanyIntegrationID: ci.ID,
1856
2477
  CredentialID: credentialID,
1857
- ConnectionTestSuccess: true,
1858
- ConnectionTestMessage: testResult.Message
2478
+ ConnectionTestSuccess: testPassed,
2479
+ ConnectionTestMessage: testMessage,
2480
+ SchemaRefresh: schemaRefreshSummary,
1859
2481
  };
1860
2482
  }
1861
2483
 
@@ -1881,6 +2503,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1881
2503
  @Arg("configuration", { nullable: true }) configuration: string,
1882
2504
  @Arg("externalSystemID", { nullable: true }) externalSystemID: string,
1883
2505
  @Arg("testConnection", () => Boolean, { defaultValue: false }) testConnection: boolean,
2506
+ @Arg("runSchemaRefresh", () => Boolean, { defaultValue: true, description: "When true (default) and TestConnection succeeds, automatically runs IntegrationConnectorCreationPipeline. Same intermittent server-side step as the create flow." }) runSchemaRefresh: boolean,
2507
+ @Arg("universalPKConvention", { nullable: true, description: "Optional vendor-wide PK hint (e.g. 'id' for HubSpot)" }) universalPKConvention: string | undefined,
1884
2508
  @Ctx() ctx: AppContext
1885
2509
  ): Promise<MutationResultOutput> {
1886
2510
  try {
@@ -1921,7 +2545,25 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1921
2545
  await this.revertUpdateConnection(ci, oldConfiguration, oldExternalSystemID, oldCredentialValues, user, md);
1922
2546
  return { Success: false, Message: `Connection test failed: ${testResult.Message}. Changes have been reverted.` };
1923
2547
  }
1924
- return { Success: true, Message: 'Updated and connection test passed' };
2548
+ }
2549
+
2550
+ // Auto-run schema refresh pipeline (intermittent server-side period).
2551
+ // Fires whenever runSchemaRefresh=true, regardless of whether the
2552
+ // caller also asked for a test — the wizard may have tested separately
2553
+ // already and is just hitting Update to save edits.
2554
+ if (runSchemaRefresh) {
2555
+ try {
2556
+ const refreshResult = await this.runSchemaRefreshPipeline(
2557
+ companyIntegrationID, user, md, universalPKConvention
2558
+ );
2559
+ return {
2560
+ Success: true,
2561
+ Message: `Updated, schema refresh: ${refreshResult.ObjectsCreated} created, ${refreshResult.ObjectsUpdated} updated, ${refreshResult.UnresolvedObjects.length} PK-unresolved`,
2562
+ };
2563
+ } catch (refreshErr) {
2564
+ LogError(`IntegrationUpdateConnection: pipeline error — ${refreshErr}`);
2565
+ return { Success: true, Message: `Updated (schema refresh failed: ${this.formatError(refreshErr)})` };
2566
+ }
1925
2567
  }
1926
2568
 
1927
2569
  return { Success: true, Message: 'Updated' };
@@ -2021,27 +2663,57 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2021
2663
  return { Success: false, Message: `No EntityID or EntityName for "${mapDef.ExternalObjectName}"`, Created: created };
2022
2664
  }
2023
2665
 
2024
- const em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', user);
2025
- em.NewRecord();
2026
- em.CompanyIntegrationID = companyIntegrationID;
2027
- em.ExternalObjectName = mapDef.ExternalObjectName;
2028
- em.EntityID = entityID;
2029
2666
  const syncDir = mapDef.SyncDirection || 'Pull';
2030
2667
  if (!isValidSyncDirection(syncDir)) {
2031
2668
  return { Success: false, Message: `Invalid SyncDirection "${syncDir}" for "${mapDef.ExternalObjectName}". Must be one of: ${VALID_SYNC_DIRECTIONS.join(', ')}`, Created: created };
2032
2669
  }
2670
+
2671
+ // Create-or-reuse by (connection, external object) — same idempotency rule as ApplyAll's
2672
+ // createSingleEntityMap. A blind NewRecord() here duplicates maps on every re-apply.
2673
+ const escapedObjectName = mapDef.ExternalObjectName.replace(/'/g, "''");
2674
+ const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
2675
+ EntityName: 'MJ: Company Integration Entity Maps',
2676
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
2677
+ OrderBy: '__mj_CreatedAt ASC',
2678
+ MaxRows: 1,
2679
+ ResultType: 'entity_object',
2680
+ BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
2681
+ }, user);
2682
+
2683
+ let em: MJCompanyIntegrationEntityMapEntity;
2684
+ if (existingMapResult.Success && existingMapResult.Results.length > 0) {
2685
+ em = existingMapResult.Results[0];
2686
+ } else {
2687
+ em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', user);
2688
+ em.NewRecord();
2689
+ em.CompanyIntegrationID = companyIntegrationID;
2690
+ em.ExternalObjectName = mapDef.ExternalObjectName;
2691
+ }
2692
+ em.EntityID = entityID;
2033
2693
  em.SyncDirection = syncDir;
2034
2694
  em.Priority = mapDef.Priority || 0;
2035
2695
  em.Status = 'Active';
2696
+ if (mapDef.Configuration != null) em.Configuration = mapDef.Configuration;
2036
2697
 
2037
2698
  if (!await em.Save()) {
2038
2699
  return { Success: false, Message: `Failed to create map for ${mapDef.ExternalObjectName}`, Created: created };
2039
2700
  }
2040
2701
  const entityMapID = em.ID;
2041
2702
 
2042
- // Create field maps if provided
2703
+ // Create field maps if provided (skip ones already mapped for this entity map)
2043
2704
  if (mapDef.FieldMaps) {
2705
+ const existingFieldMaps = await new RunView().RunView<{ SourceFieldName: string }>({
2706
+ EntityName: 'MJ: Company Integration Field Maps',
2707
+ ExtraFilter: `EntityMapID='${entityMapID}'`,
2708
+ Fields: ['SourceFieldName'],
2709
+ ResultType: 'simple',
2710
+ BypassCache: true, // read committed field-map state for the idempotency skip
2711
+ }, user);
2712
+ const alreadyMapped = new Set(
2713
+ (existingFieldMaps.Results ?? []).map(r => (r.SourceFieldName ?? '').toLowerCase())
2714
+ );
2044
2715
  for (const fmDef of mapDef.FieldMaps) {
2716
+ if (alreadyMapped.has((fmDef.SourceFieldName ?? '').toLowerCase())) continue;
2045
2717
  const fm = await md.GetEntityObject<MJCompanyIntegrationFieldMapEntity>('MJ: Company Integration Field Maps', user);
2046
2718
  fm.NewRecord();
2047
2719
  fm.EntityMapID = entityMapID;
@@ -2092,13 +2764,28 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2092
2764
  const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
2093
2765
  const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user, provider);
2094
2766
 
2095
- const introspect = connector.IntrospectSchema.bind(connector) as
2096
- (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
2097
- const sourceSchema = await introspect(companyIntegration, user);
2767
+ // Reconstruct source schema from the IO/IOF rows already persisted by
2768
+ // the Phase 0 v5.39.x Save hook. Fall back to live IntrospectSchema only
2769
+ // for direct-API callers bypassing the wizard (empty IO cache).
2770
+ const requestedNames = new Set(objects.map(o => o.SourceObjectName));
2771
+ let sourceSchema: SourceSchemaInfo = this.buildSourceSchemaFromPersistedRows(
2772
+ companyIntegration.IntegrationID,
2773
+ Array.from(requestedNames),
2774
+ );
2775
+ if (sourceSchema.Objects.length === 0) {
2776
+ LogError(`[IntegrationApplySchema] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
2777
+ const introspect = connector.IntrospectSchema.bind(connector) as
2778
+ (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
2779
+ sourceSchema = await introspect(companyIntegration, user);
2780
+ } else {
2781
+ console.log(
2782
+ `[IntegrationApplySchema] Reusing ${sourceSchema.Objects.length} persisted IOs for ${companyIntegration.Integration} ` +
2783
+ `— skipped duplicate IntrospectSchema (Save-hook already discovered).`
2784
+ );
2785
+ }
2098
2786
 
2099
2787
  await this.resolveObjectInputs(objects, sourceSchema, user);
2100
2788
 
2101
- const requestedNames = new Set(objects.map(o => o.SourceObjectName));
2102
2789
  const filteredSchema: SourceSchemaInfo = {
2103
2790
  Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
2104
2791
  };
@@ -2242,71 +2929,78 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2242
2929
  const { connector, companyIntegration } = await this.resolveConnector(input.CompanyIntegrationID, user, provider);
2243
2930
  const schemaName = this.deriveSchemaName(companyIntegration.Integration);
2244
2931
 
2245
- // Step 1b: Ensure IntegrationEngine cache is populated so IntrospectSchema's
2246
- // DB fallback (GetCachedObject/GetCachedFields) can find IntegrationObject records
2932
+ // Step 1b: Ensure IntegrationEngine cache is populated so the persisted
2933
+ // IO/IOF rows are available for reconstruction below.
2247
2934
  await IntegrationEngine.Instance.Config(false, user);
2248
2935
 
2249
- // Step 2: Introspect source schema and persist discovered objects/fields
2250
- const sourceSchema = await (connector.IntrospectSchema.bind(connector) as
2251
- (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>)(companyIntegration, user);
2252
-
2253
- // Step 2b: Persist discovered objects/fields to IntegrationObject/IntegrationObjectField.
2254
- // Static records (IsCustom=false) are preserved; new/custom records get IsCustom=true.
2255
- // This ensures custom objects are available for future sync runs, action generation, etc.
2256
- try {
2257
- const persistResult = await IntegrationSchemaSync.PersistDiscoveredSchema({
2258
- IntegrationID: companyIntegration.IntegrationID,
2259
- SourceSchema: sourceSchema,
2260
- ContextUser: user,
2261
- });
2262
- if (persistResult.ObjectsCreated > 0 || persistResult.FieldsCreated > 0) {
2263
- console.log(
2264
- `[IntegrationApplyAll] Persisted discovered schema: ` +
2265
- `${persistResult.ObjectsCreated} new objects, ${persistResult.FieldsCreated} new fields, ` +
2266
- `${persistResult.ObjectsUpdated} updated objects, ${persistResult.FieldsUpdated} updated fields`
2267
- );
2268
- }
2269
-
2270
- // Step 2c: Generate CRUD actions for newly discovered custom objects.
2271
- // Uses the same ActionMetadataGenerator as the offline CLI, persisted via BaseEntity.Save().
2272
- if (persistResult.ObjectsCreated > 0) {
2273
- try {
2274
- const engineObjects = IntegrationEngine.Instance
2275
- .GetIntegrationObjectsByIntegrationID(companyIntegration.IntegrationID);
2276
- const customObjects = sourceSchema.Objects
2277
- .filter(o => !engineObjects
2278
- .some(ex => ex.Name.toLowerCase() === o.ExternalName.toLowerCase() && !ex.IsCustom))
2279
- .map(o => ({
2280
- Name: o.ExternalName,
2281
- DisplayName: o.ExternalLabel || o.ExternalName,
2282
- Description: o.Description,
2283
- SupportsWrite: false,
2284
- Fields: o.Fields.map(f => ({
2285
- Name: f.Name,
2286
- DisplayName: f.Label || f.Name,
2287
- Description: f.Description || '',
2288
- Type: f.SourceType || 'string',
2289
- IsRequired: f.IsRequired,
2290
- IsReadOnly: false,
2291
- IsPrimaryKey: f.IsPrimaryKey,
2292
- })),
2293
- }));
2294
- await IntegrationSchemaSync.GenerateActionsForCustomObjects({
2295
- IntegrationName: companyIntegration.Integration,
2296
- CustomObjects: customObjects,
2297
- SupportsSearch: connector.SupportsSearch,
2298
- SupportsListing: connector.SupportsListing,
2299
- ContextUser: user,
2300
- });
2301
- } catch (actionErr) {
2302
- const msg = actionErr instanceof Error ? actionErr.message : String(actionErr);
2303
- console.warn(`[IntegrationApplyAll] Action generation warning (non-fatal): ${msg}`);
2936
+ // Step 2: Reconstruct SourceSchemaInfo from the persisted IO/IOF rows
2937
+ // (already freshened by the Phase 0 v5.39.x MJCompanyIntegrationEntityServer
2938
+ // Save hook on IsActive false→true). Avoids the duplicate vendor-API
2939
+ // introspect that used to fire here.
2940
+ let sourceSchema: SourceSchemaInfo = this.buildSourceSchemaFromPersistedRows(companyIntegration.IntegrationID);
2941
+ if (sourceSchema.Objects.length === 0) {
2942
+ // Fallback: the engine cache is empty (Save hook didn't run, or this
2943
+ // is a direct-API caller bypassing the wizard). Do a one-time live
2944
+ // introspect + persist + action-generation so the apply still proceeds.
2945
+ LogError(`[IntegrationApplyAll] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
2946
+ sourceSchema = await (connector.IntrospectSchema.bind(connector) as
2947
+ (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>)(companyIntegration, user);
2948
+ try {
2949
+ const persistResult = await IntegrationSchemaSync.PersistDiscoveredSchema({
2950
+ IntegrationID: companyIntegration.IntegrationID,
2951
+ SourceSchema: sourceSchema,
2952
+ ContextUser: user,
2953
+ });
2954
+ if (persistResult.ObjectsCreated > 0 || persistResult.FieldsCreated > 0) {
2955
+ console.log(
2956
+ `[IntegrationApplyAll] Fallback persist: ` +
2957
+ `${persistResult.ObjectsCreated} new objects, ${persistResult.FieldsCreated} new fields, ` +
2958
+ `${persistResult.ObjectsUpdated} updated objects, ${persistResult.FieldsUpdated} updated fields`
2959
+ );
2960
+ }
2961
+ if (persistResult.ObjectsCreated > 0) {
2962
+ try {
2963
+ const engineObjects = IntegrationEngine.Instance
2964
+ .GetIntegrationObjectsByIntegrationID(companyIntegration.IntegrationID);
2965
+ const customObjects = sourceSchema.Objects
2966
+ .filter(o => !engineObjects
2967
+ .some(ex => ex.Name.toLowerCase() === o.ExternalName.toLowerCase() && !ex.IsCustom))
2968
+ .map(o => ({
2969
+ Name: o.ExternalName,
2970
+ DisplayName: o.ExternalLabel || o.ExternalName,
2971
+ Description: o.Description,
2972
+ SupportsWrite: false,
2973
+ Fields: o.Fields.map(f => ({
2974
+ Name: f.Name,
2975
+ DisplayName: f.Label || f.Name,
2976
+ Description: f.Description || '',
2977
+ Type: f.SourceType || 'string',
2978
+ IsRequired: f.IsRequired,
2979
+ IsReadOnly: false,
2980
+ IsPrimaryKey: f.IsPrimaryKey,
2981
+ })),
2982
+ }));
2983
+ await IntegrationSchemaSync.GenerateActionsForCustomObjects({
2984
+ IntegrationName: companyIntegration.Integration,
2985
+ CustomObjects: customObjects,
2986
+ SupportsSearch: connector.SupportsSearch,
2987
+ SupportsListing: connector.SupportsListing,
2988
+ ContextUser: user,
2989
+ });
2990
+ } catch (actionErr) {
2991
+ const msg = actionErr instanceof Error ? actionErr.message : String(actionErr);
2992
+ console.warn(`[IntegrationApplyAll] Action generation warning (non-fatal): ${msg}`);
2993
+ }
2304
2994
  }
2995
+ } catch (persistErr) {
2996
+ const msg = persistErr instanceof Error ? persistErr.message : String(persistErr);
2997
+ console.warn(`[IntegrationApplyAll] Schema persistence warning (non-fatal): ${msg}`);
2305
2998
  }
2306
- } catch (persistErr) {
2307
- // Non-fatal: schema persistence failure should not block table creation
2308
- const msg = persistErr instanceof Error ? persistErr.message : String(persistErr);
2309
- console.warn(`[IntegrationApplyAll] Schema persistence warning (non-fatal): ${msg}`);
2999
+ } else {
3000
+ console.log(
3001
+ `[IntegrationApplyAll] Reusing ${sourceSchema.Objects.length} persisted IOs for ${companyIntegration.Integration} ` +
3002
+ `— skipped duplicate IntrospectSchema (Save-hook already discovered).`
3003
+ );
2310
3004
  }
2311
3005
 
2312
3006
  const resolved = await this.resolveSourceObjectsToNames(input.SourceObjects, sourceSchema, user);
@@ -2516,11 +3210,30 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2516
3210
  return null;
2517
3211
  }
2518
3212
 
2519
- // Create entity map
2520
- const em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', user);
2521
- em.NewRecord();
2522
- em.CompanyIntegrationID = companyIntegrationID;
2523
- em.ExternalObjectName = obj.SourceObjectName;
3213
+ // Create-or-reuse the entity map. Idempotency is REQUIRED here: ApplyAll is re-run on
3214
+ // every wizard re-apply (and by the test harness on every pull). Blindly NewRecord()-ing
3215
+ // multiplies the maps in lockstep (N applies → N duplicate maps per object), which
3216
+ // silently corrupts the record-map 1:1 completeness gate and makes the forward sync
3217
+ // process each object N times. Reuse the existing (connection, external object) map instead.
3218
+ const escapedObjectName = obj.SourceObjectName.replace(/'/g, "''");
3219
+ const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3220
+ EntityName: 'MJ: Company Integration Entity Maps',
3221
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
3222
+ OrderBy: '__mj_CreatedAt ASC',
3223
+ MaxRows: 1,
3224
+ ResultType: 'entity_object',
3225
+ BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3226
+ }, user);
3227
+
3228
+ let em: MJCompanyIntegrationEntityMapEntity;
3229
+ if (existingMapResult.Success && existingMapResult.Results.length > 0) {
3230
+ em = existingMapResult.Results[0]; // reuse — keeps the map stable across re-applies
3231
+ } else {
3232
+ em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', user);
3233
+ em.NewRecord();
3234
+ em.CompanyIntegrationID = companyIntegrationID;
3235
+ em.ExternalObjectName = obj.SourceObjectName;
3236
+ }
2524
3237
  em.EntityID = entityInfo.ID;
2525
3238
  em.SyncDirection = isValidSyncDirection(defaultSyncDirection) ? defaultSyncDirection : 'Pull';
2526
3239
  em.Priority = obj.SourceObjectName.startsWith('assoc_') ? 10 : 0;
@@ -2560,7 +3273,24 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2560
3273
  (ci: unknown, obj: string, u: unknown) => Promise<ExternalFieldSchema[]>;
2561
3274
  const fields = await discoverFields(companyIntegration, sourceObjectName, user);
2562
3275
 
3276
+ // Idempotency (mirrors the entity-map reuse above): don't re-create field maps that
3277
+ // already exist for this entity map, or re-applies multiply them in lockstep.
3278
+ const existingFieldMaps = await new RunView().RunView<{ SourceFieldName: string }>({
3279
+ EntityName: 'MJ: Company Integration Field Maps',
3280
+ ExtraFilter: `EntityMapID='${entityMapID}'`,
3281
+ Fields: ['SourceFieldName'],
3282
+ ResultType: 'simple',
3283
+ BypassCache: true, // read committed field-map state for the idempotency skip
3284
+ }, user);
3285
+ const alreadyMapped = new Set(
3286
+ (existingFieldMaps.Results ?? []).map(r => (r.SourceFieldName ?? '').toLowerCase())
3287
+ );
3288
+
2563
3289
  for (const field of fields) {
3290
+ if (alreadyMapped.has(field.Name.toLowerCase())) {
3291
+ fieldCount++; // already present — count it so the reported total stays stable
3292
+ continue;
3293
+ }
2564
3294
  const fm = await md.GetEntityObject<MJCompanyIntegrationFieldMapEntity>('MJ: Company Integration Field Maps', user);
2565
3295
  fm.NewRecord();
2566
3296
  fm.EntityMapID = entityMapID;
@@ -2633,20 +3363,38 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2633
3363
  ): Promise<{ schemaOutput: SchemaBuilderOutput; rsuInput: RSUPipelineInput }> {
2634
3364
  const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user, provider);
2635
3365
 
2636
- // If the caller already ran IntrospectSchema (e.g. IntegrationApplyAllBatch),
2637
- // reuse it. The legacy path was running introspect TWICE per apply once
2638
- // in the resolver and once here which doubled probe time on connectors
2639
- // like Sage Intacct AND silently dropped selections when the second pass
2640
- // returned fewer objects than the first (rate limits, transient errors).
2641
- // The picked items would then fail to match `filteredSchema` below and
2642
- // get silently stripped before reaching buildTargetConfigs.
3366
+ // Source-schema resolution order:
3367
+ // 1. Use prefetched schema if the caller passed one (legacy ApplyAllBatch path).
3368
+ // 2. Reconstruct from persisted IO/IOF rowsthe Phase 0 v5.39.x Save hook
3369
+ // already discovered + persisted everything when the wizard flipped
3370
+ // `IsActive false→true`. No need to re-hit the vendor API.
3371
+ // 3. Only when both above are empty do we fall back to live IntrospectSchema
3372
+ // (direct-API callers bypassing the wizard).
3373
+ //
3374
+ // Pre-Phase-0 the legacy path ran introspect TWICE per apply — once in the
3375
+ // resolver and once here — which doubled probe time on connectors like Sage
3376
+ // Intacct AND silently dropped selections when the second pass returned
3377
+ // fewer objects than the first (rate limits, transient errors).
2643
3378
  let sourceSchema: SourceSchemaInfo;
2644
3379
  if (prefetchedSourceSchema) {
2645
3380
  sourceSchema = prefetchedSourceSchema;
2646
3381
  } else {
2647
- const introspect = connector.IntrospectSchema.bind(connector) as
2648
- (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
2649
- sourceSchema = await introspect(companyIntegration, user);
3382
+ const requestedNamesForReuse = objects.map(o => o.SourceObjectName);
3383
+ sourceSchema = this.buildSourceSchemaFromPersistedRows(
3384
+ companyIntegration.IntegrationID,
3385
+ requestedNamesForReuse,
3386
+ );
3387
+ if (sourceSchema.Objects.length === 0) {
3388
+ LogError(`[buildSchemaForConnector] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
3389
+ const introspect = connector.IntrospectSchema.bind(connector) as
3390
+ (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
3391
+ sourceSchema = await introspect(companyIntegration, user);
3392
+ } else {
3393
+ console.log(
3394
+ `[buildSchemaForConnector] Reusing ${sourceSchema.Objects.length} persisted IOs for ${companyIntegration.Integration} ` +
3395
+ `— skipped duplicate IntrospectSchema (Save-hook already discovered).`
3396
+ );
3397
+ }
2650
3398
  }
2651
3399
 
2652
3400
  // Normalize names to match source schema casing
@@ -2706,6 +3454,17 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2706
3454
  const user = this.getAuthenticatedUser(ctx);
2707
3455
  await IntegrationEngine.Instance.Config(false, user);
2708
3456
 
3457
+ // Reject upfront if the connector is deactivated. The engine also gates this
3458
+ // authoritatively (no run record is created), but returning Success:false here gives the
3459
+ // client immediate, unambiguous feedback instead of an optimistic fire-and-forget that
3460
+ // silently no-ops. IsActive is boolean|null — only an explicit false rejects (null/unset
3461
+ // connections predating the flag are unaffected).
3462
+ const ciProvider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
3463
+ const ciCheck = await ciProvider.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
3464
+ if (await ciCheck.InnerLoad(CompositeKey.FromID(companyIntegrationID)) && ciCheck.IsActive === false) {
3465
+ return { Success: false, Message: 'Connector is deactivated (IsActive=false); sync not started' };
3466
+ }
3467
+
2709
3468
  const syncOptions: { FullSync?: boolean; EntityMapIDs?: string[]; SyncDirection?: 'Pull' | 'Push' | 'Bidirectional' } = {};
2710
3469
  if (fullSync) syncOptions.FullSync = true;
2711
3470
  if (entityMapIDs?.length) syncOptions.EntityMapIDs = entityMapIDs;
@@ -2829,9 +3588,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2829
3588
 
2830
3589
  const companyIntegration = ciResult.Results[0];
2831
3590
 
2832
- // Load the Integration entity to get the ClassName for connector resolution
3591
+ // Load the Integration entity to get the ClassName for connector resolution.
3592
+ // Entity is registered as 'MJ: Integrations' (the "MJ: " prefix is required — the sibling
3593
+ // CompanyIntegration lookup above uses 'MJ: Company Integrations'); the bare 'Integrations'
3594
+ // name is not in metadata, which dead-lettered the entire IntegrationWriteRecord (push) path.
2833
3595
  const integResult = await rv.RunView<MJIntegrationEntity>({
2834
- EntityName: 'Integrations',
3596
+ EntityName: 'MJ: Integrations',
2835
3597
  ExtraFilter: `ID='${companyIntegration.IntegrationID}'`,
2836
3598
  MaxRows: 1,
2837
3599
  ResultType: 'entity_object',
@@ -3133,7 +3895,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3133
3895
  ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
3134
3896
  OrderBy: 'Priority ASC',
3135
3897
  ResultType: 'simple',
3136
- Fields: ['ID', 'EntityID', 'Entity', 'ExternalObjectName', 'SyncDirection', 'Priority', 'Status']
3898
+ Fields: ['ID', 'EntityID', 'Entity', 'ExternalObjectName', 'SyncDirection', 'Priority', 'Status', 'Configuration'],
3899
+ BypassCache: true, // operational list must reflect COMMITTED state (wizard/lifecycle act on it)
3137
3900
  }, user);
3138
3901
 
3139
3902
  if (!result.Success) return { Success: false, Message: result.ErrorMessage || 'Query failed' };
@@ -3161,6 +3924,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3161
3924
  ExtraFilter: `EntityMapID='${entityMapID}'`,
3162
3925
  OrderBy: 'SourceFieldName',
3163
3926
  ResultType: 'simple',
3927
+ BypassCache: true, // operational list must reflect COMMITTED state
3164
3928
  Fields: ['ID', 'EntityMapID', 'SourceFieldName', 'DestinationFieldName', 'Status']
3165
3929
  }, user);
3166
3930
 
@@ -3199,6 +3963,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3199
3963
  em.SyncDirection = update.SyncDirection;
3200
3964
  }
3201
3965
  if (update.Priority != null) em.Priority = update.Priority;
3966
+ if (update.Configuration != null) em.Configuration = update.Configuration;
3202
3967
  if (update.Status != null) {
3203
3968
  if (!isValidEntityMapStatus(update.Status)) {
3204
3969
  errors.push(`${update.EntityMapID}: invalid Status "${update.Status}"`);
@@ -3207,7 +3972,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3207
3972
  em.Status = update.Status;
3208
3973
  }
3209
3974
 
3210
- if (!await em.Save()) errors.push(`${update.EntityMapID}: failed to save`);
3975
+ if (!await em.Save()) errors.push(`${update.EntityMapID}: failed to save — ${em.LatestResult?.CompleteMessage ?? 'unknown error'}`);
3211
3976
  }
3212
3977
 
3213
3978
  if (errors.length > 0) return { Success: false, Message: `Errors: ${errors.join('; ')}` };
@@ -3359,7 +4124,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3359
4124
  EntityName: 'MJ: Company Integration Entity Maps',
3360
4125
  ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
3361
4126
  ResultType: 'simple',
3362
- Fields: ['ID', 'Status']
4127
+ Fields: ['ID', 'Status'],
4128
+ // Status must reflect the live state immediately after a deselect/reselect
4129
+ // (UpdateEntityMaps). Bypass the server RunView cache so ActiveEntityMaps never
4130
+ // reports a stale count right after a map's Status is toggled.
4131
+ BypassCache: true
3363
4132
  },
3364
4133
  {
3365
4134
  EntityName: 'MJ: Company Integration Runs',
@@ -3367,7 +4136,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3367
4136
  OrderBy: 'StartedAt DESC',
3368
4137
  MaxRows: 1,
3369
4138
  ResultType: 'simple',
3370
- Fields: ['ID', 'Status', 'StartedAt', 'EndedAt', 'TotalRecords']
4139
+ Fields: ['ID', 'Status', 'StartedAt', 'EndedAt', 'TotalRecords'],
4140
+ BypassCache: true
3371
4141
  }
3372
4142
  ], user);
3373
4143
 
@@ -3433,6 +4203,199 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3433
4203
  }
3434
4204
  }
3435
4205
 
4206
+ // ── STRUCTURED RUN ARTIFACTS (durable, growing JSONL streams) ────────
4207
+
4208
+ /**
4209
+ * Lists integration runs (sync, connector-creation, RSU, discovery, …) from the
4210
+ * durable progress-artifact store, newest-first. Use this to render run history
4211
+ * with live status for a multi-tenant control surface. Optionally scope to a
4212
+ * single connector and/or run kind, or to only in-flight runs.
4213
+ */
4214
+ @Query(() => IntegrationListRunsOutput)
4215
+ async IntegrationListRuns(
4216
+ @Ctx() ctx: AppContext,
4217
+ @Arg("companyIntegrationID", { nullable: true }) companyIntegrationID?: string,
4218
+ @Arg("runKind", { nullable: true }) runKind?: string,
4219
+ @Arg("inFlightOnly", { nullable: true }) inFlightOnly?: boolean,
4220
+ @Arg("limit", { defaultValue: 50 }) limit?: number,
4221
+ ): Promise<IntegrationListRunsOutput> {
4222
+ try {
4223
+ const user = this.getAuthenticatedUser(ctx);
4224
+
4225
+ // When a specific connector is requested, authorize it up front so an
4226
+ // unauthorized caller gets a clear denial rather than an empty list.
4227
+ const authCache = new Map<string, boolean>();
4228
+ if (companyIntegrationID) {
4229
+ const authorized = await this.userCanReadCompanyIntegration(companyIntegrationID, user, authCache);
4230
+ if (!authorized) {
4231
+ return { Success: false, Message: this.notAuthorizedForCompanyIntegrationMessage(companyIntegrationID) };
4232
+ }
4233
+ }
4234
+
4235
+ const reader = new IntegrationProgressReader();
4236
+ const snaps = await reader.ListRuns({
4237
+ companyIntegrationID,
4238
+ runKind: runKind as IntegrationRunKind | undefined,
4239
+ inFlightOnly: inFlightOnly ?? false,
4240
+ }, limit ?? 50);
4241
+
4242
+ // Filter to only the runs the caller is authorized to read. When
4243
+ // scoped to a single (already-authorized) connector this is a no-op;
4244
+ // for the cross-connector listing it prevents one tenant from seeing
4245
+ // another tenant's runs.
4246
+ const authorizedSnaps = await this.filterAuthorizedRuns(snaps, user, authCache);
4247
+ return { Success: true, Message: `${authorizedSnaps.length} run(s)`, Runs: authorizedSnaps.map(s => this.toRunSummaryArtifact(s)) };
4248
+ } catch (e) {
4249
+ LogError(`IntegrationListRuns error: ${e}`);
4250
+ return { Success: false, Message: this.formatError(e) };
4251
+ }
4252
+ }
4253
+
4254
+ /** Returns only the run snapshots the caller is authorized to read. */
4255
+ private async filterAuthorizedRuns(
4256
+ snaps: IntegrationRunSnapshot[],
4257
+ user: UserInfo,
4258
+ authCache: Map<string, boolean>
4259
+ ): Promise<IntegrationRunSnapshot[]> {
4260
+ const authorized: IntegrationRunSnapshot[] = [];
4261
+ for (const snap of snaps) {
4262
+ if (await this.userCanReadRunArtifact(snap, user, authCache)) {
4263
+ authorized.push(snap);
4264
+ }
4265
+ }
4266
+ return authorized;
4267
+ }
4268
+
4269
+ /**
4270
+ * Returns the summary of a single run (manifest + terminal result + latest counts).
4271
+ * Pair with IntegrationTailRunEvents to read the full event stream.
4272
+ */
4273
+ @Query(() => IntegrationRunDetailOutput)
4274
+ async IntegrationGetRun(
4275
+ @Arg("runID") runID: string,
4276
+ @Ctx() ctx: AppContext,
4277
+ ): Promise<IntegrationRunDetailOutput> {
4278
+ try {
4279
+ const user = this.getAuthenticatedUser(ctx);
4280
+ const reader = new IntegrationProgressReader();
4281
+ const snap = await reader.GetRun(runID);
4282
+ if (!snap) return { Success: false, Message: `Run '${runID}' not found` };
4283
+
4284
+ const authorized = await this.userCanReadRunArtifact(snap, user, new Map<string, boolean>());
4285
+ if (!authorized) {
4286
+ const ciID = snap.manifest.companyIntegrationID;
4287
+ return {
4288
+ Success: false,
4289
+ Message: ciID
4290
+ ? this.notAuthorizedForCompanyIntegrationMessage(ciID)
4291
+ : `Not authorized to access run '${runID}'`,
4292
+ };
4293
+ }
4294
+
4295
+ return {
4296
+ Success: true,
4297
+ Message: 'OK',
4298
+ Run: this.toRunSummaryArtifact(snap),
4299
+ Errors: snap.result?.errors?.map(er => er.stage ? `[${er.stage}] ${er.message}` : er.message),
4300
+ };
4301
+ } catch (e) {
4302
+ LogError(`IntegrationGetRun error: ${e}`);
4303
+ return { Success: false, Message: this.formatError(e) };
4304
+ }
4305
+ }
4306
+
4307
+ /**
4308
+ * Tails a run's structured event stream from a given sequence number. The stream
4309
+ * grows over the life of the run, so a client polls with the last LatestSeq it
4310
+ * saw to fetch only the new events. IsInFlight=false signals the run is terminal
4311
+ * and polling can stop.
4312
+ */
4313
+ @Query(() => IntegrationRunEventsOutput)
4314
+ async IntegrationTailRunEvents(
4315
+ @Arg("runID") runID: string,
4316
+ @Ctx() ctx: AppContext,
4317
+ @Arg("sinceSeq", { defaultValue: 0 }) sinceSeq?: number,
4318
+ ): Promise<IntegrationRunEventsOutput> {
4319
+ try {
4320
+ const user = this.getAuthenticatedUser(ctx);
4321
+ const reader = new IntegrationProgressReader();
4322
+ const snap = await reader.GetRun(runID);
4323
+ if (!snap) return { Success: false, Message: `Run '${runID}' not found`, LatestSeq: sinceSeq ?? 0, IsInFlight: false };
4324
+
4325
+ const authorized = await this.userCanReadRunArtifact(snap, user, new Map<string, boolean>());
4326
+ if (!authorized) {
4327
+ const ciID = snap.manifest.companyIntegrationID;
4328
+ return {
4329
+ Success: false,
4330
+ Message: ciID
4331
+ ? this.notAuthorizedForCompanyIntegrationMessage(ciID)
4332
+ : `Not authorized to access run '${runID}'`,
4333
+ LatestSeq: sinceSeq ?? 0,
4334
+ IsInFlight: false,
4335
+ };
4336
+ }
4337
+
4338
+ const events = await reader.Tail(runID, sinceSeq ?? 0);
4339
+ const latestSeq = events.length > 0 ? events[events.length - 1].seq : (sinceSeq ?? 0);
4340
+ return {
4341
+ Success: true,
4342
+ Message: `${events.length} event(s)`,
4343
+ Events: events.map(ev => ({
4344
+ Ts: ev.ts,
4345
+ Seq: ev.seq,
4346
+ EventType: ev.eventType,
4347
+ Level: ev.level,
4348
+ Stage: ev.stage,
4349
+ Message: ev.message,
4350
+ Counts: this.toCountsOutput(ev.counts),
4351
+ DataJSON: ev.data ? JSON.stringify(ev.data) : undefined,
4352
+ ResumableStateJSON: ev.resumableState ? JSON.stringify(ev.resumableState) : undefined,
4353
+ })),
4354
+ LatestSeq: latestSeq,
4355
+ IsInFlight: snap.isInFlight,
4356
+ };
4357
+ } catch (e) {
4358
+ LogError(`IntegrationTailRunEvents error: ${e}`);
4359
+ return { Success: false, Message: this.formatError(e), LatestSeq: sinceSeq ?? 0, IsInFlight: false };
4360
+ }
4361
+ }
4362
+
4363
+ /** Maps a reader snapshot to the GraphQL summary shape. */
4364
+ private toRunSummaryArtifact(s: IntegrationRunSnapshot): IntegrationRunSummaryArtifactOutput {
4365
+ return {
4366
+ RunID: s.manifest.runID,
4367
+ RunKind: s.manifest.runKind,
4368
+ IntegrationID: s.manifest.integrationID,
4369
+ CompanyIntegrationID: s.manifest.companyIntegrationID,
4370
+ ObjectName: s.manifest.objectName,
4371
+ TriggerType: s.manifest.triggerType,
4372
+ StartedAt: s.manifest.startedAt,
4373
+ IsInFlight: s.isInFlight,
4374
+ EventCount: s.eventCount,
4375
+ Success: s.result?.success,
4376
+ ExitReason: s.result?.exitReason,
4377
+ CompletedAt: s.result?.completedAt,
4378
+ DurationMs: s.result?.durationMs,
4379
+ LatestEventType: s.latestEvent?.eventType,
4380
+ LatestMessage: s.latestEvent?.message,
4381
+ Counts: this.toCountsOutput(s.counts),
4382
+ WarningCount: s.warningCount,
4383
+ Warnings: s.warnings?.map(w => `[${w.code}] ${w.stage}: ${w.message}`),
4384
+ };
4385
+ }
4386
+
4387
+ /** Maps the reader's lowercase counts shape to the PascalCase GraphQL output. */
4388
+ private toCountsOutput(c?: { processed?: number; succeeded?: number; failed?: number; skipped?: number; totalKnown?: number }): IntegrationRunCountsOutput | undefined {
4389
+ if (!c) return undefined;
4390
+ return {
4391
+ Processed: c.processed,
4392
+ Succeeded: c.succeeded,
4393
+ Failed: c.failed,
4394
+ Skipped: c.skipped,
4395
+ TotalKnown: c.totalKnown,
4396
+ };
4397
+ }
4398
+
3436
4399
  // ── CONNECTOR CAPABILITIES ──────────────────────────────────────────
3437
4400
 
3438
4401
  /**
@@ -3555,29 +4518,42 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3555
4518
  fieldsByName.set(p.Name.toLowerCase(), p.Fields);
3556
4519
  }
3557
4520
  } else {
3558
- // Legacy path (HubSpot, YourMembership, Sage Intacct, etc.)
3559
- // — describe all, persist all, then resolve by either
3560
- // SourceObjectID (legacy clients) OR SourceObjectName
3561
- // (newly-discovered objects from connectors that probe
3562
- // their full catalog at picker time, e.g. SI's 666
3563
- // candidates). Without this fallback, freshly-probed
3564
- // selections silently drop.
3565
- sourceSchema = await (connector.IntrospectSchema.bind(connector) as
3566
- (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>)(companyIntegration, user);
3567
-
3568
- try {
3569
- const persistResult = await IntegrationSchemaSync.PersistDiscoveredSchema({
3570
- IntegrationID: companyIntegration.IntegrationID,
3571
- SourceSchema: sourceSchema,
3572
- ContextUser: user,
3573
- });
4521
+ // Legacy path (HubSpot, YourMembership, Sage Intacct, etc.).
4522
+ //
4523
+ // Phase 0 v5.39.x change: the `MJCompanyIntegrationEntityServer.Save()`
4524
+ // hook already ran the full discovery + persist pipeline when
4525
+ // the wizard's Finish flipped `IsActive false→true`. The IO/IOF
4526
+ // rows in `IntegrationEngineBase`'s cache are already fresh.
4527
+ // Calling `connector.IntrospectSchema()` again here just to feed
4528
+ // `SchemaBuilder` doubles the vendor-API roundtrip — on HubSpot
4529
+ // (130 objects, 60+ DiscoverFields probes) that's an extra ~100s
4530
+ // wasted per Apply. Reconstruct `SourceSchemaInfo` from the
4531
+ // persisted rows instead.
4532
+ //
4533
+ // Persist is also skipped here — the Save hook already did it.
4534
+ sourceSchema = this.buildSourceSchemaFromPersistedRows(companyIntegration.IntegrationID);
4535
+ if (sourceSchema.Objects.length === 0) {
4536
+ // Defensive fallback: if the engine cache is empty (hook
4537
+ // didn't run, or this is a direct-API caller that bypasses
4538
+ // the wizard), do a one-time live introspect + persist so
4539
+ // the apply still proceeds. Rare.
4540
+ LogError(`[IntegrationApplyAllBatch] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
4541
+ sourceSchema = await (connector.IntrospectSchema.bind(connector) as
4542
+ (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>)(companyIntegration, user);
4543
+ try {
4544
+ await IntegrationSchemaSync.PersistDiscoveredSchema({
4545
+ IntegrationID: companyIntegration.IntegrationID,
4546
+ SourceSchema: sourceSchema,
4547
+ ContextUser: user,
4548
+ });
4549
+ } catch (persistErr) {
4550
+ LogError(`IntegrationApplyAllBatch: PersistDiscoveredSchema fallback failed for ${companyIntegration.Integration}: ${persistErr}`);
4551
+ }
4552
+ } else {
3574
4553
  console.log(
3575
- `[IntegrationApplyAllBatch] Persisted discovered schema for ${companyIntegration.Integration}: ` +
3576
- `${persistResult.ObjectsCreated} new objects, ${persistResult.FieldsCreated} new fields, ` +
3577
- `${persistResult.ObjectsUpdated} updated objects, ${persistResult.FieldsUpdated} updated fields`
4554
+ `[IntegrationApplyAllBatch] Reusing ${sourceSchema.Objects.length} persisted IOs for ${companyIntegration.Integration} ` +
4555
+ `— skipped duplicate IntrospectSchema (Save-hook already discovered).`
3578
4556
  );
3579
- } catch (persistErr) {
3580
- LogError(`IntegrationApplyAllBatch: PersistDiscoveredSchema failed for ${companyIntegration.Integration}: ${persistErr}`);
3581
4557
  }
3582
4558
 
3583
4559
  // Resolve names from BOTH ID lookups and direct names.
@@ -4024,10 +5000,24 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4024
5000
  }
4025
5001
  }
4026
5002
 
4027
- // Step 7: Delete the CompanyIntegration itself
5003
+ // Step 7: Delete the CompanyIntegration itself FIRST, so its CredentialID FK reference
5004
+ // is gone before we delete the Credential. (Deleting the Credential first violates the
5005
+ // CompanyIntegration.CredentialID foreign key — the constraint is checked per-statement
5006
+ // inside the transaction — and rolls back the entire cascade.) Capture CredentialID
5007
+ // before Delete() since we still need it for Step 8.
5008
+ const linkedCredentialID = ci.CredentialID;
4028
5009
  ci.TransactionGroup = tg;
4029
5010
  await ci.Delete();
4030
5011
 
5012
+ // Step 8: Delete the linked Credential (encrypted-credential row) in the SAME
5013
+ // transaction group so it commits/rolls back atomically with the cascade — now safe
5014
+ // because the referencing CompanyIntegration delete is queued ahead of it. Skips
5015
+ // silently when there is no CredentialID, and refuses to delete a credential still
5016
+ // referenced by ANOTHER CompanyIntegration (shared credential).
5017
+ const credentialDeleted = await this.cascadeDeleteCredential(
5018
+ linkedCredentialID, companyIntegrationID, tg, rv, md, sysUser
5019
+ );
5020
+
4031
5021
  // Submit the transaction
4032
5022
  const submitted = await tg.Submit();
4033
5023
  if (!submitted) {
@@ -4044,6 +5034,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4044
5034
  EntityMapsDeleted: entityMapsDeleted,
4045
5035
  FieldMapsDeleted: fieldMapsDeleted,
4046
5036
  SchedulesDeleted: schedulesDeleted,
5037
+ CredentialDeleted: credentialDeleted,
4047
5038
  };
4048
5039
  } catch (e) {
4049
5040
  LogError(`IntegrationDeleteConnection error: ${e}`);