@trayio/cdk-dsl 5.29.0 → 5.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +595 -0
  2. 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.29.0",
3
+ "version": "5.30.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.29.0"
17
+ "@trayio/commons": "5.30.0"
18
18
  },
19
19
  "typesVersions": {
20
20
  "*": {