@trayio/cdk-dsl 5.29.0 → 5.31.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 +595 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -642,6 +642,601 @@ Finally, for DDL operations, we need to add an extra `type` property in the `ope
|
|
|
642
642
|
|
|
643
643
|
This will categorise this operation as a DDL and will exclude it from the list of visible operations of the connector.
|
|
644
644
|
|
|
645
|
+
## Trigger Connectors
|
|
646
|
+
|
|
647
|
+
Trigger connectors enable workflows to start automatically when specific events occur in external systems.
|
|
648
|
+
Unlike regular connectors that execute operations on-demand, trigger connectors listen for events and initiate workflow execution when those events are detected.
|
|
649
|
+
|
|
650
|
+
The CDK supports two types of trigger connectors:
|
|
651
|
+
|
|
652
|
+
- **Webhook triggers** (also called "normal" triggers): Receive real-time events via HTTP webhooks from external services
|
|
653
|
+
- **Polling triggers**: Periodically check external services for new data or changes
|
|
654
|
+
|
|
655
|
+
To create a trigger connector, the `connector.json` file must include the `isTrigger` property set to `true`:
|
|
656
|
+
|
|
657
|
+
```json
|
|
658
|
+
{
|
|
659
|
+
"name": "my_trigger_connector",
|
|
660
|
+
"title": "My Trigger Connector",
|
|
661
|
+
"version": "1.0.0",
|
|
662
|
+
"isTrigger": true
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
Setting `isTrigger: true` disables the automatic injection of the raw HTTP operation that regular connectors receive.
|
|
667
|
+
This is necessary because trigger connectors have their own specialized lifecycle operations.
|
|
668
|
+
|
|
669
|
+
Each trigger operation must specify its `triggerType` in the `operation.json` file:
|
|
670
|
+
|
|
671
|
+
```json
|
|
672
|
+
{
|
|
673
|
+
"name": "new_records",
|
|
674
|
+
"title": "New Records",
|
|
675
|
+
"description": "Triggers when new records are created",
|
|
676
|
+
"triggerType": "polling" // or "normal" for webhook triggers
|
|
677
|
+
}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
## Webhook Triggers
|
|
681
|
+
|
|
682
|
+
Webhook triggers receive real-time events from external services via HTTP callbacks.
|
|
683
|
+
They are ideal when the external service supports webhooks and you need immediate notification of events.
|
|
684
|
+
|
|
685
|
+
A webhook trigger connector implements four lifecycle handlers:
|
|
686
|
+
|
|
687
|
+
### Create Handler
|
|
688
|
+
|
|
689
|
+
The create handler registers the webhook with the external service:
|
|
690
|
+
|
|
691
|
+
```typescript
|
|
692
|
+
export const newOrderCreateHandler =
|
|
693
|
+
OperationHandlerSetup.configureTriggerCreateHandler<AuthType, CreateInput>(
|
|
694
|
+
(handler) =>
|
|
695
|
+
handler.usingHttp((http) =>
|
|
696
|
+
http
|
|
697
|
+
.post('https://api.example.com/webhooks')
|
|
698
|
+
.handleRequest((ctx, input, request) =>
|
|
699
|
+
request
|
|
700
|
+
.withBearerToken(ctx.auth!.user.access_token)
|
|
701
|
+
.withBodyAsJson({
|
|
702
|
+
url: input.webhookUrl,
|
|
703
|
+
events: input.eventTypes,
|
|
704
|
+
secret: input.webhookSecret,
|
|
705
|
+
})
|
|
706
|
+
)
|
|
707
|
+
.handleResponse((ctx, input, response) =>
|
|
708
|
+
response.parseWithBodyAsJson()
|
|
709
|
+
)
|
|
710
|
+
)
|
|
711
|
+
);
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
### Destroy Handler
|
|
715
|
+
|
|
716
|
+
The destroy handler unregisters the webhook when the trigger is disabled:
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
export const newOrderDestroyHandler =
|
|
720
|
+
OperationHandlerSetup.configureTriggerDestroyHandler<AuthType, DestroyInput>(
|
|
721
|
+
(handler) =>
|
|
722
|
+
handler.usingHttp((http) =>
|
|
723
|
+
http
|
|
724
|
+
.delete('https://api.example.com/webhooks/:webhookId')
|
|
725
|
+
.handleRequest((ctx, input, request) =>
|
|
726
|
+
request
|
|
727
|
+
.withBearerToken(ctx.auth!.user.access_token)
|
|
728
|
+
.addPathParameter('webhookId', input.webhookId)
|
|
729
|
+
.withoutBody()
|
|
730
|
+
)
|
|
731
|
+
.handleResponse((ctx, input, response) =>
|
|
732
|
+
response.parseWithBodyAsJson()
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
);
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
### Request Handler
|
|
739
|
+
|
|
740
|
+
The request handler processes incoming webhook HTTP requests and extracts the event data:
|
|
741
|
+
|
|
742
|
+
```typescript
|
|
743
|
+
export const newOrderRequestHandler =
|
|
744
|
+
OperationHandlerSetup.configureTriggerRequestHandler<
|
|
745
|
+
AuthType,
|
|
746
|
+
RequestInput,
|
|
747
|
+
OrderEvent
|
|
748
|
+
>((handler) =>
|
|
749
|
+
handler.usingComposite(async (ctx, input) => {
|
|
750
|
+
// input.request is a RegularTriggerOperationHttpRequest (hasLargeBody: false, body: string)
|
|
751
|
+
// or a LargeTriggerOperationHttpRequest (hasLargeBody: true, body_url instead of body)
|
|
752
|
+
if (input.request.hasLargeBody) {
|
|
753
|
+
return OperationHandlerResult.failure(
|
|
754
|
+
OperationHandlerError.userInputError(
|
|
755
|
+
'Unexpected large webhook payload'
|
|
756
|
+
)
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Verify webhook signature - header values are string[]
|
|
761
|
+
const [signature] = input.request.headers['x-webhook-signature'] ?? [];
|
|
762
|
+
if (
|
|
763
|
+
!verifySignature(
|
|
764
|
+
input.request.body,
|
|
765
|
+
signature,
|
|
766
|
+
input.input.webhookSecret
|
|
767
|
+
)
|
|
768
|
+
) {
|
|
769
|
+
return OperationHandlerResult.failure(
|
|
770
|
+
OperationHandlerError.userInputError('Invalid webhook signature')
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Parse and return the event
|
|
775
|
+
const order = JSON.parse(input.request.body);
|
|
776
|
+
return OperationHandlerResult.success({
|
|
777
|
+
output: {
|
|
778
|
+
event: order,
|
|
779
|
+
eventId: order.id,
|
|
780
|
+
eventTimestamp: order.created_at,
|
|
781
|
+
},
|
|
782
|
+
});
|
|
783
|
+
})
|
|
784
|
+
);
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### Response/Event Handler
|
|
788
|
+
|
|
789
|
+
The response handler builds the HTTP response to send back to the webhook sender, or the event handler emits the event to the workflow:
|
|
790
|
+
|
|
791
|
+
```typescript
|
|
792
|
+
// For synchronous response
|
|
793
|
+
export const newOrderResponseHandler =
|
|
794
|
+
OperationHandlerSetup.configureTriggerResponseHandler<
|
|
795
|
+
AuthType,
|
|
796
|
+
ResponseInput,
|
|
797
|
+
OrderEvent
|
|
798
|
+
>((handler) =>
|
|
799
|
+
handler.usingComposite(async (ctx, input) =>
|
|
800
|
+
OperationHandlerResult.success({
|
|
801
|
+
status_code: 200,
|
|
802
|
+
headers: { 'Content-Type': ['application/json'] },
|
|
803
|
+
body: JSON.stringify({ received: true }),
|
|
804
|
+
})
|
|
805
|
+
)
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
// Or for asynchronous event emission
|
|
809
|
+
export const newOrderEventHandler =
|
|
810
|
+
OperationHandlerSetup.configureTriggerEventHandler<
|
|
811
|
+
AuthType,
|
|
812
|
+
EventInput,
|
|
813
|
+
OrderEvent
|
|
814
|
+
>((handler) =>
|
|
815
|
+
handler.usingComposite(async (ctx, input) =>
|
|
816
|
+
OperationHandlerResult.success({
|
|
817
|
+
id: input.response.eventId,
|
|
818
|
+
eventType: 'order.created',
|
|
819
|
+
data: JSON.stringify(input.response.event),
|
|
820
|
+
})
|
|
821
|
+
)
|
|
822
|
+
);
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
## Polling Triggers
|
|
826
|
+
|
|
827
|
+
Polling triggers periodically check external services for new data or changes.
|
|
828
|
+
They are useful when the external service doesn't support webhooks or when you need to batch-process changes.
|
|
829
|
+
|
|
830
|
+
Polling triggers use the Tray Polling Service to manage the polling schedule, cursor state persistence, and failure handling.
|
|
831
|
+
The connector implements four lifecycle handlers that work together with the Polling Service.
|
|
832
|
+
|
|
833
|
+
### Polling Configuration
|
|
834
|
+
|
|
835
|
+
Polling triggers accept configuration for the polling interval and failure handling:
|
|
836
|
+
|
|
837
|
+
```typescript
|
|
838
|
+
import { PollingTriggerInput } from '@trayio/cdk-dsl/connector/operation/PollingTypes';
|
|
839
|
+
|
|
840
|
+
export type NewRecordsInput = PollingTriggerInput & {
|
|
841
|
+
// Platform-managed fields (injected automatically):
|
|
842
|
+
// - pollingInterval: { value: number, unit: 'minutes' | 'hours' }
|
|
843
|
+
// - maxFailureCount: number (1-150, default 30)
|
|
844
|
+
// - publicUrl: string (the workflow trigger URL)
|
|
845
|
+
|
|
846
|
+
// Your connector-specific fields:
|
|
847
|
+
objectType: string;
|
|
848
|
+
includeDeleted?: boolean;
|
|
849
|
+
fields?: string[];
|
|
850
|
+
};
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
The platform enforces these validation rules:
|
|
854
|
+
|
|
855
|
+
- Minimum polling interval: 5 minutes or 1 hour depending on the unit
|
|
856
|
+
- Maximum failure count: between 1 and 150 (default is 30)
|
|
857
|
+
|
|
858
|
+
### Create Handler
|
|
859
|
+
|
|
860
|
+
The create handler registers the polling subscription with the Polling Service.
|
|
861
|
+
Unlike webhook triggers, the handler body is a no-op - the runtime intercepts this operation and calls the Polling Service directly:
|
|
862
|
+
|
|
863
|
+
```typescript
|
|
864
|
+
export const newRecordsCreateHandler =
|
|
865
|
+
OperationHandlerSetup.configureTriggerPollCreateHandler<
|
|
866
|
+
AuthType,
|
|
867
|
+
NewRecordsInput
|
|
868
|
+
>();
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
The runtime automatically attaches validation for `pollingInterval` and `maxFailureCount` to ensure they meet the platform requirements.
|
|
872
|
+
|
|
873
|
+
### Destroy Handler
|
|
874
|
+
|
|
875
|
+
The destroy handler deregisters the polling subscription.
|
|
876
|
+
Like the create handler, it has a no-op body - the runtime handles the Polling Service communication:
|
|
877
|
+
|
|
878
|
+
```typescript
|
|
879
|
+
export const newRecordsDestroyHandler =
|
|
880
|
+
OperationHandlerSetup.configureTriggerPollDestroyHandler<
|
|
881
|
+
AuthType,
|
|
882
|
+
NewRecordsInput
|
|
883
|
+
>();
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
The runtime always sends `preserveCursor: true` when deregistering, so if the trigger is re-enabled later, it will resume from where it left off rather than starting over.
|
|
887
|
+
|
|
888
|
+
### Poll Request Handler
|
|
889
|
+
|
|
890
|
+
The poll request handler is where your actual polling logic lives.
|
|
891
|
+
It receives the current cursor and returns new events along with the next cursor:
|
|
892
|
+
|
|
893
|
+
```typescript
|
|
894
|
+
import { OperationHandlerResult } from '@trayio/cdk-dsl/connector/operation/OperationHandler';
|
|
895
|
+
import { DynamicObject } from '@trayio/commons/dynamictype/DynamicType';
|
|
896
|
+
|
|
897
|
+
type RecordEvent = {
|
|
898
|
+
id: string;
|
|
899
|
+
type: string;
|
|
900
|
+
created: string;
|
|
901
|
+
data: Record<string, unknown>;
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
export const newRecordsPollHandler =
|
|
905
|
+
OperationHandlerSetup.configureTriggerPollRequestHandler<
|
|
906
|
+
AuthType,
|
|
907
|
+
NewRecordsInput,
|
|
908
|
+
RecordEvent
|
|
909
|
+
>((handler) =>
|
|
910
|
+
handler.usingHttp((http) =>
|
|
911
|
+
http
|
|
912
|
+
.get('https://api.example.com/records')
|
|
913
|
+
.handleRequest((ctx, input, request) => {
|
|
914
|
+
// Input contains: { input: NewRecordsInput, cursor: string | null }
|
|
915
|
+
const since =
|
|
916
|
+
input.cursor || new Date(Date.now() - 86400000).toISOString();
|
|
917
|
+
|
|
918
|
+
return request
|
|
919
|
+
.withBearerToken(ctx.auth!.user.access_token)
|
|
920
|
+
.addQueryString('type', input.input.objectType)
|
|
921
|
+
.addQueryString('since', since)
|
|
922
|
+
.addQueryString('limit', '100')
|
|
923
|
+
.withoutBody();
|
|
924
|
+
})
|
|
925
|
+
.handleResponse((ctx, input, response) =>
|
|
926
|
+
// handleResponse must return an HttpOperationResponse, so the
|
|
927
|
+
// result is built inside the parseWithBodyAsJson callback
|
|
928
|
+
response.parseWithBodyAsJson((body: DynamicObject) => {
|
|
929
|
+
const records = body.records as unknown as RecordEvent[];
|
|
930
|
+
|
|
931
|
+
// Find the latest timestamp to use as next cursor
|
|
932
|
+
const latestTimestamp =
|
|
933
|
+
records.length > 0
|
|
934
|
+
? Math.max(...records.map((r) => new Date(r.created).getTime()))
|
|
935
|
+
: Date.parse(input.cursor ?? new Date().toISOString());
|
|
936
|
+
|
|
937
|
+
return OperationHandlerResult.success({
|
|
938
|
+
events: records,
|
|
939
|
+
nextCursor: new Date(latestTimestamp).toISOString(),
|
|
940
|
+
canPollMore: body.hasMore === true,
|
|
941
|
+
});
|
|
942
|
+
})
|
|
943
|
+
)
|
|
944
|
+
)
|
|
945
|
+
);
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
The output must include:
|
|
949
|
+
|
|
950
|
+
- `events`: Array of events to emit to the workflow
|
|
951
|
+
- `nextCursor`: The cursor to use for the next poll (persisted by the Polling Service)
|
|
952
|
+
- `canPollMore`: If true, the Polling Service will immediately poll again (useful for pagination)
|
|
953
|
+
|
|
954
|
+
### Deduplication Handler
|
|
955
|
+
|
|
956
|
+
The deduplication handler computes a unique ID for each event to prevent duplicate processing:
|
|
957
|
+
|
|
958
|
+
```typescript
|
|
959
|
+
import { OperationHandlerResult } from '@trayio/cdk-dsl/connector/operation/OperationHandler';
|
|
960
|
+
|
|
961
|
+
export const newRecordsDedupHandler =
|
|
962
|
+
OperationHandlerSetup.configureTriggerPollDedupHandler<
|
|
963
|
+
AuthType,
|
|
964
|
+
NewRecordsInput,
|
|
965
|
+
RecordEvent
|
|
966
|
+
>((handler) =>
|
|
967
|
+
handler.usingComposite(async (ctx, input) => {
|
|
968
|
+
// Input contains: { event: RecordEvent }
|
|
969
|
+
const event = input.event;
|
|
970
|
+
|
|
971
|
+
// Generate a stable, unique ID for this event
|
|
972
|
+
const dedupId = `${event.type}_${event.id}_${event.created}`;
|
|
973
|
+
|
|
974
|
+
return OperationHandlerResult.success({
|
|
975
|
+
triggerDeduplicationId: dedupId,
|
|
976
|
+
});
|
|
977
|
+
})
|
|
978
|
+
);
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
The platform uses the `triggerDeduplicationId` to drop events that have already been processed, ensuring exactly-once delivery semantics even if the same data is polled multiple times.
|
|
982
|
+
|
|
983
|
+
### Cursor Management
|
|
984
|
+
|
|
985
|
+
The Polling Service persists the cursor between poll cycles.
|
|
986
|
+
Your poll handler should:
|
|
987
|
+
|
|
988
|
+
1. Use the provided cursor to determine where to start fetching data
|
|
989
|
+
2. Return a `nextCursor` that represents the point to resume from next time
|
|
990
|
+
3. Design cursors to be stable and resumable (timestamps, IDs, or offset values work well)
|
|
991
|
+
|
|
992
|
+
Example cursor strategies:
|
|
993
|
+
|
|
994
|
+
```typescript
|
|
995
|
+
// Timestamp-based cursor for time-ordered data
|
|
996
|
+
const since = input.cursor || new Date(Date.now() - 3600000).toISOString(); // Default to 1 hour ago
|
|
997
|
+
// ... fetch records created after 'since'
|
|
998
|
+
const nextCursor = latestRecord.created_at;
|
|
999
|
+
|
|
1000
|
+
// ID-based cursor for sequentially-numbered records
|
|
1001
|
+
const afterId = input.cursor || '0';
|
|
1002
|
+
// ... fetch records with ID > afterId
|
|
1003
|
+
const nextCursor = latestRecord.id;
|
|
1004
|
+
|
|
1005
|
+
// Offset-based cursor for paginated APIs
|
|
1006
|
+
const offset = parseInt(input.cursor || '0');
|
|
1007
|
+
// ... fetch records with OFFSET offset LIMIT 100
|
|
1008
|
+
const nextCursor = (offset + recordCount).toString();
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
### Error Handling
|
|
1012
|
+
|
|
1013
|
+
The Polling Service tracks consecutive failures and stops polling after `maxFailureCount` failures (default 30).
|
|
1014
|
+
When the failure threshold is reached:
|
|
1015
|
+
|
|
1016
|
+
1. The Polling Service stops scheduling new poll cycles
|
|
1017
|
+
2. A synthetic error event is emitted to the workflow with details about the failure
|
|
1018
|
+
3. The error is emitted before the deduplication handler runs
|
|
1019
|
+
|
|
1020
|
+
You can use `OperationHandlerError.skipTriggerError()` to indicate non-critical errors that shouldn't count toward the failure limit:
|
|
1021
|
+
|
|
1022
|
+
```typescript
|
|
1023
|
+
.handleResponse((ctx, input, response) => {
|
|
1024
|
+
if (response.getStatusCode() === 429) {
|
|
1025
|
+
// Rate limit - skip this poll but don't count as failure.
|
|
1026
|
+
// parseWithoutBody keeps the return type an HttpOperationResponse
|
|
1027
|
+
// even though this branch never reads the response body.
|
|
1028
|
+
return response.parseWithoutBody(() =>
|
|
1029
|
+
OperationHandlerResult.failure(
|
|
1030
|
+
OperationHandlerError.skipTriggerError('Rate limited, will retry next interval')
|
|
1031
|
+
)
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
// ... normal processing, returned via response.parseWithBodyAsJson(...)
|
|
1035
|
+
})
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
### Complete Polling Trigger Example
|
|
1039
|
+
|
|
1040
|
+
Here's a complete example of a polling trigger connector that monitors for new records in an external system:
|
|
1041
|
+
|
|
1042
|
+
**Directory structure:**
|
|
1043
|
+
|
|
1044
|
+
```
|
|
1045
|
+
my-polling-connector/
|
|
1046
|
+
├── connector.json
|
|
1047
|
+
├── src/
|
|
1048
|
+
│ ├── Auth.ts
|
|
1049
|
+
│ └── new_records/
|
|
1050
|
+
│ ├── operation.json
|
|
1051
|
+
│ ├── input.ts
|
|
1052
|
+
│ ├── output.ts
|
|
1053
|
+
│ ├── handler.ts
|
|
1054
|
+
│ └── handler.test.ts
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
**connector.json:**
|
|
1058
|
+
|
|
1059
|
+
```json
|
|
1060
|
+
{
|
|
1061
|
+
"name": "my_polling_connector",
|
|
1062
|
+
"title": "My Polling Connector",
|
|
1063
|
+
"version": "1.0.0",
|
|
1064
|
+
"description": "Polls for new records",
|
|
1065
|
+
"isTrigger": true
|
|
1066
|
+
}
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
**src/new_records/operation.json:**
|
|
1070
|
+
|
|
1071
|
+
```json
|
|
1072
|
+
{
|
|
1073
|
+
"name": "new_records",
|
|
1074
|
+
"title": "New Records",
|
|
1075
|
+
"description": "Triggers when new records are created",
|
|
1076
|
+
"triggerType": "polling"
|
|
1077
|
+
}
|
|
1078
|
+
```
|
|
1079
|
+
|
|
1080
|
+
**src/new_records/input.ts:**
|
|
1081
|
+
|
|
1082
|
+
```typescript
|
|
1083
|
+
import { PollingTriggerInput } from '@trayio/cdk-dsl/connector/operation/PollingTypes';
|
|
1084
|
+
|
|
1085
|
+
export type NewRecordsInput = PollingTriggerInput & {
|
|
1086
|
+
/**
|
|
1087
|
+
* @title Record Type
|
|
1088
|
+
* @description The type of records to monitor
|
|
1089
|
+
*/
|
|
1090
|
+
recordType: 'contact' | 'account' | 'opportunity';
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* @title Include Archived
|
|
1094
|
+
* @description Include archived records in results
|
|
1095
|
+
* @default false
|
|
1096
|
+
*/
|
|
1097
|
+
includeArchived?: boolean;
|
|
1098
|
+
};
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
**src/new_records/output.ts:**
|
|
1102
|
+
|
|
1103
|
+
```typescript
|
|
1104
|
+
export type NewRecordsOutput = {
|
|
1105
|
+
id: string;
|
|
1106
|
+
type: string;
|
|
1107
|
+
name: string;
|
|
1108
|
+
createdAt: string;
|
|
1109
|
+
data: Record<string, unknown>;
|
|
1110
|
+
};
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
**src/new_records/handler.ts:**
|
|
1114
|
+
|
|
1115
|
+
```typescript
|
|
1116
|
+
import { OperationHandlerSetup } from '@trayio/cdk-dsl/connector/operation/OperationHandlerSetup';
|
|
1117
|
+
import {
|
|
1118
|
+
OperationHandlerResult,
|
|
1119
|
+
OperationHandlerError,
|
|
1120
|
+
} from '@trayio/cdk-dsl/connector/operation/OperationHandler';
|
|
1121
|
+
import { DynamicObject } from '@trayio/commons/dynamictype/DynamicType';
|
|
1122
|
+
import { AuthType } from '../Auth';
|
|
1123
|
+
import { NewRecordsInput } from './input';
|
|
1124
|
+
import { NewRecordsOutput } from './output';
|
|
1125
|
+
|
|
1126
|
+
// Create handler - registers the polling subscription
|
|
1127
|
+
export const newRecordsCreateHandler =
|
|
1128
|
+
OperationHandlerSetup.configureTriggerPollCreateHandler<
|
|
1129
|
+
AuthType,
|
|
1130
|
+
NewRecordsInput
|
|
1131
|
+
>();
|
|
1132
|
+
|
|
1133
|
+
// Destroy handler - deregisters the polling subscription
|
|
1134
|
+
export const newRecordsDestroyHandler =
|
|
1135
|
+
OperationHandlerSetup.configureTriggerPollDestroyHandler<
|
|
1136
|
+
AuthType,
|
|
1137
|
+
NewRecordsInput
|
|
1138
|
+
>();
|
|
1139
|
+
|
|
1140
|
+
// Poll request handler - fetches new records since last cursor
|
|
1141
|
+
export const newRecordsPollHandler =
|
|
1142
|
+
OperationHandlerSetup.configureTriggerPollRequestHandler<
|
|
1143
|
+
AuthType,
|
|
1144
|
+
NewRecordsInput,
|
|
1145
|
+
NewRecordsOutput
|
|
1146
|
+
>((handler) =>
|
|
1147
|
+
handler.usingHttp((http) =>
|
|
1148
|
+
http
|
|
1149
|
+
.get('https://api.example.com/v1/records')
|
|
1150
|
+
.handleRequest((ctx, input, request) => {
|
|
1151
|
+
// Default to 24 hours ago if no cursor
|
|
1152
|
+
const since =
|
|
1153
|
+
input.cursor || new Date(Date.now() - 86400000).toISOString();
|
|
1154
|
+
|
|
1155
|
+
return request
|
|
1156
|
+
.withBearerToken(ctx.auth!.user.access_token)
|
|
1157
|
+
.addQueryString('type', input.input.recordType)
|
|
1158
|
+
.addQueryString('created_after', since)
|
|
1159
|
+
.addQueryString(
|
|
1160
|
+
'include_archived',
|
|
1161
|
+
input.input.includeArchived ? 'true' : 'false'
|
|
1162
|
+
)
|
|
1163
|
+
.addQueryString('limit', '100')
|
|
1164
|
+
.addQueryString('sort', 'created_at:asc')
|
|
1165
|
+
.withoutBody();
|
|
1166
|
+
})
|
|
1167
|
+
.handleResponse((ctx, input, response) => {
|
|
1168
|
+
// Handle rate limiting gracefully. parseWithoutBody keeps the
|
|
1169
|
+
// return type an HttpOperationResponse without reading the body.
|
|
1170
|
+
if (response.getStatusCode() === 429) {
|
|
1171
|
+
return response.parseWithoutBody(() =>
|
|
1172
|
+
OperationHandlerResult.failure(
|
|
1173
|
+
OperationHandlerError.skipTriggerError(
|
|
1174
|
+
'API rate limit reached, will retry at next interval'
|
|
1175
|
+
)
|
|
1176
|
+
)
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
return response.parseWithBodyAsJson((body: DynamicObject) => {
|
|
1181
|
+
const records = body.data as unknown as NewRecordsOutput[];
|
|
1182
|
+
|
|
1183
|
+
// No new records - keep current cursor
|
|
1184
|
+
if (records.length === 0) {
|
|
1185
|
+
return OperationHandlerResult.success({
|
|
1186
|
+
events: [],
|
|
1187
|
+
nextCursor: input.cursor || new Date().toISOString(),
|
|
1188
|
+
canPollMore: false,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Use the latest record's timestamp as next cursor
|
|
1193
|
+
const latestTimestamp = records[records.length - 1].createdAt;
|
|
1194
|
+
|
|
1195
|
+
return OperationHandlerResult.success({
|
|
1196
|
+
events: records,
|
|
1197
|
+
nextCursor: latestTimestamp,
|
|
1198
|
+
canPollMore: body.has_more === true,
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
})
|
|
1202
|
+
)
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
// Deduplication handler - generates unique IDs to prevent duplicate events
|
|
1206
|
+
export const newRecordsDedupHandler =
|
|
1207
|
+
OperationHandlerSetup.configureTriggerPollDedupHandler<
|
|
1208
|
+
AuthType,
|
|
1209
|
+
NewRecordsInput,
|
|
1210
|
+
NewRecordsOutput
|
|
1211
|
+
>((handler) =>
|
|
1212
|
+
handler.usingComposite(async (ctx, input) => {
|
|
1213
|
+
const record = input.event;
|
|
1214
|
+
|
|
1215
|
+
// Create a stable unique ID for this record
|
|
1216
|
+
const dedupId = `${record.type}:${record.id}:${record.createdAt}`;
|
|
1217
|
+
|
|
1218
|
+
return OperationHandlerResult.success({
|
|
1219
|
+
triggerDeduplicationId: dedupId,
|
|
1220
|
+
});
|
|
1221
|
+
})
|
|
1222
|
+
);
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
### CLI Limitations
|
|
1226
|
+
|
|
1227
|
+
Currently, the `tray-cdk add-operation` command only supports scaffolding `http` and `composite` operation types.
|
|
1228
|
+
Trigger and polling trigger operations must be created manually following the patterns shown above.
|
|
1229
|
+
|
|
1230
|
+
To create a trigger operation:
|
|
1231
|
+
|
|
1232
|
+
1. Create the operation folder manually under `src/`
|
|
1233
|
+
2. Add the `operation.json` file with the appropriate `triggerType`
|
|
1234
|
+
3. Create `input.ts` and `output.ts` type definitions
|
|
1235
|
+
4. Implement the required handlers in `handler.ts`
|
|
1236
|
+
5. Add tests in `handler.test.ts`
|
|
1237
|
+
|
|
1238
|
+
Future versions of the CLI may add support for scaffolding trigger operations.
|
|
1239
|
+
|
|
645
1240
|
## Dynamic Output Schema
|
|
646
1241
|
|
|
647
1242
|
Some operations may need to generate their output schema dynamically at runtime based on the actual API response structure. This is useful when different inputs result in different output structures, or when the API response schema varies by resource type.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trayio/cdk-dsl",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.31.0",
|
|
4
4
|
"description": "A DSL for connector development",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./*": "./dist/*.js"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"access": "public"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@trayio/commons": "5.
|
|
17
|
+
"@trayio/commons": "5.31.0"
|
|
18
18
|
},
|
|
19
19
|
"typesVersions": {
|
|
20
20
|
"*": {
|