@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.
- package/README.md +79 -12
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +16 -12
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/apolloServer/index.d.ts +0 -8
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/apolloServer/index.js +61 -0
- package/dist/apolloServer/index.js.map +1 -1
- package/dist/config.d.ts +68 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +24 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +2 -21
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +172 -5
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +867 -14
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
- package/dist/generic/FireAndForgetHeartbeat.js +44 -0
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +177 -16
- package/dist/index.js.map +1 -1
- package/dist/logging/NoLog.d.ts +50 -0
- package/dist/logging/NoLog.d.ts.map +1 -0
- package/dist/logging/NoLog.js +80 -0
- package/dist/logging/NoLog.js.map +1 -0
- package/dist/logging/bootAudit.d.ts +43 -0
- package/dist/logging/bootAudit.d.ts.map +1 -0
- package/dist/logging/bootAudit.js +83 -0
- package/dist/logging/bootAudit.js.map +1 -0
- package/dist/logging/boundaryLogPayload.d.ts +18 -0
- package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
- package/dist/logging/boundaryLogPayload.js +18 -0
- package/dist/logging/boundaryLogPayload.js.map +1 -0
- package/dist/logging/secretRedactor.d.ts +23 -0
- package/dist/logging/secretRedactor.d.ts.map +1 -0
- package/dist/logging/secretRedactor.js +53 -0
- package/dist/logging/secretRedactor.js.map +1 -0
- package/dist/logging/shortenForLog.d.ts +8 -0
- package/dist/logging/shortenForLog.d.ts.map +1 -0
- package/dist/logging/shortenForLog.js +21 -0
- package/dist/logging/shortenForLog.js.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.js +127 -0
- package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +8 -4
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
- package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
- package/dist/resolvers/IntegrationProgressResolver.js +196 -0
- package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
- package/dist/resolvers/MCPResolver.d.ts.map +1 -1
- package/dist/resolvers/MCPResolver.js +19 -14
- package/dist/resolvers/MCPResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +5 -4
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +6 -18
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +32 -64
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +12 -2
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
- package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
- package/dist/resolvers/SearchEntitiesResolver.js +216 -0
- package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -1
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/dist/services/ScheduledJobsService.d.ts.map +1 -1
- package/dist/services/ScheduledJobsService.js +14 -2
- package/dist/services/ScheduledJobsService.js.map +1 -1
- package/package.json +75 -74
- package/src/__tests__/NoLog.test.ts +76 -0
- package/src/__tests__/bootAudit.test.ts +188 -0
- package/src/__tests__/boundaryLogPayload.test.ts +31 -0
- package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
- package/src/__tests__/resolverBase.rls.test.ts +224 -0
- package/src/__tests__/secretRedactor.test.ts +163 -0
- package/src/__tests__/subscriptionRedaction.test.ts +217 -0
- package/src/agents/skip-sdk.ts +16 -13
- package/src/apolloServer/index.ts +58 -0
- package/src/config.ts +27 -0
- package/src/context.ts +2 -19
- package/src/generated/generated.ts +627 -15
- package/src/generic/FireAndForgetHeartbeat.ts +85 -0
- package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
- package/src/index.ts +199 -19
- package/src/logging/NoLog.ts +88 -0
- package/src/logging/bootAudit.ts +117 -0
- package/src/logging/boundaryLogPayload.ts +19 -0
- package/src/logging/secretRedactor.ts +82 -0
- package/src/logging/shortenForLog.ts +17 -0
- package/src/logging/variablesLoggingMiddleware.ts +191 -0
- package/src/resolvers/GetDataResolver.ts +9 -5
- package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
- package/src/resolvers/IntegrationProgressResolver.ts +220 -0
- package/src/resolvers/MCPResolver.ts +18 -14
- package/src/resolvers/QueryResolver.ts +7 -6
- package/src/resolvers/QuerySystemUserResolver.ts +11 -25
- package/src/resolvers/RunAIAgentResolver.ts +34 -71
- package/src/resolvers/RunTestResolver.ts +14 -2
- package/src/resolvers/SearchEntitiesResolver.ts +173 -0
- package/src/resolvers/VectorizeEntityResolver.ts +14 -1
- package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
- 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:
|
|
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:
|
|
1858
|
-
ConnectionTestMessage:
|
|
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
|
-
|
|
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
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
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
|
|
2246
|
-
//
|
|
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:
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
)
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
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
|
-
}
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
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
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
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
|
-
//
|
|
2637
|
-
//
|
|
2638
|
-
//
|
|
2639
|
-
//
|
|
2640
|
-
//
|
|
2641
|
-
//
|
|
2642
|
-
//
|
|
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 rows — the 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
|
|
2648
|
-
|
|
2649
|
-
|
|
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
|
-
//
|
|
3560
|
-
//
|
|
3561
|
-
//
|
|
3562
|
-
//
|
|
3563
|
-
//
|
|
3564
|
-
//
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
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]
|
|
3576
|
-
|
|
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}`);
|