@shipfox/api-triggers 11.0.0 → 12.0.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-triggers",
3
3
  "license": "MIT",
4
- "version": "11.0.0",
4
+ "version": "12.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -22,23 +22,23 @@
22
22
  "cron-parser": "^5.6.1",
23
23
  "drizzle-orm": "^0.45.2",
24
24
  "zod": "^4.4.3",
25
- "@shipfox/api-auth-context": "11.0.0",
26
- "@shipfox/api-definitions-dto": "11.0.0",
27
- "@shipfox/api-integration-core-dto": "9.0.2",
25
+ "@shipfox/api-auth-context": "12.0.0",
26
+ "@shipfox/api-workflows-dto": "12.0.0",
27
+ "@shipfox/api-integration-core-dto": "12.0.0",
28
28
  "@shipfox/api-triggers-dto": "9.0.2",
29
- "@shipfox/api-workflows-dto": "10.0.0",
30
29
  "@shipfox/config": "1.2.4",
31
- "@shipfox/expression": "1.2.1",
32
- "@shipfox/inter-module": "0.2.2",
33
- "@shipfox/node-drizzle": "0.3.4",
30
+ "@shipfox/api-definitions-dto": "12.0.0",
31
+ "@shipfox/inter-module": "0.2.3",
32
+ "@shipfox/node-drizzle": "0.3.5",
33
+ "@shipfox/expression": "2.0.0",
34
34
  "@shipfox/node-error-monitoring": "0.3.0",
35
- "@shipfox/node-fastify": "0.4.0",
36
- "@shipfox/node-module": "1.0.4",
35
+ "@shipfox/node-module": "1.0.5",
36
+ "@shipfox/node-fastify": "0.4.1",
37
37
  "@shipfox/node-opentelemetry": "0.6.3",
38
38
  "@shipfox/node-outbox": "0.2.6",
39
- "@shipfox/node-postgres": "0.4.4",
40
39
  "@shipfox/node-temporal": "0.4.4",
41
- "@shipfox/workflow-document": "2.1.3"
40
+ "@shipfox/node-postgres": "0.5.0",
41
+ "@shipfox/workflow-document": "3.0.0"
42
42
  },
43
43
  "imports": {
44
44
  "#*": "./dist/*"
@@ -1,3 +1,4 @@
1
+ import * as expression from '@shipfox/expression';
1
2
  import {evaluateStoredFilter, evaluateTriggerFilter, readConfigInputs} from './config.js';
2
3
  import type {TriggerSubscription} from './entities/subscription.js';
3
4
 
@@ -89,6 +90,28 @@ describe('evaluateTriggerFilter', () => {
89
90
  expect(misses).toEqual({kind: 'filtered'});
90
91
  });
91
92
 
93
+ test('passes trigger filters through the field-specific context projection', () => {
94
+ const projectContext = vi.spyOn(expression, 'projectWorkflowPredicateContext');
95
+ const payload = {ref: 'refs/heads/main'};
96
+
97
+ try {
98
+ const result = evaluateTriggerFilter({
99
+ subscription: subscriptionWithConfig({filter: 'event.ref == "refs/heads/main"'}),
100
+ source: 'github',
101
+ event: 'push',
102
+ payload,
103
+ });
104
+
105
+ expect(result).toEqual({kind: 'matched'});
106
+ expect(projectContext).toHaveBeenCalledWith('trigger.filter', {
107
+ event: payload,
108
+ trigger: {source: 'github', event: 'push'},
109
+ });
110
+ } finally {
111
+ projectContext.mockRestore();
112
+ }
113
+ });
114
+
92
115
  test('returns filter-error when the stored filter cannot be parsed', () => {
93
116
  const subscription = subscriptionWithConfig({filter: 'event.ref =='});
94
117
 
@@ -2,6 +2,7 @@ import {
2
2
  createWorkflowExpression,
3
3
  evaluateWorkflowPredicateFailClosed,
4
4
  InvalidWorkflowExpressionError,
5
+ projectWorkflowPredicateContext,
5
6
  type WorkflowExpression,
6
7
  } from '@shipfox/expression';
7
8
  import type {TriggerSubscription} from './entities/subscription.js';
@@ -70,10 +71,10 @@ export function evaluateTriggerFilter(
70
71
  ): TriggerFilterEvaluation {
71
72
  return evaluateStoredFilter({
72
73
  value: params.subscription.config.filter,
73
- context: {
74
+ context: projectWorkflowPredicateContext('trigger.filter', {
74
75
  event: params.payload,
75
76
  trigger: {source: params.source, event: params.event},
76
- },
77
+ }),
77
78
  invalidReason: 'Trigger subscription filter must be a non-empty string when set',
78
79
  evaluationFailedReason: 'Trigger filter evaluation failed',
79
80
  });
@@ -2,6 +2,7 @@ import {triggerSubscriptionFactory} from '#test/index.js';
2
2
 
3
3
  const runWorkflow = vi.fn();
4
4
  const deliverEventToListener = vi.fn();
5
+ const resolveWorkflowRunTriggerReference = vi.fn();
5
6
  const insertReceivedEvent = vi.fn();
6
7
 
7
8
  vi.mock('#db/event-history.js', () => ({
@@ -24,6 +25,8 @@ const {dispatchIntegrationEvent} = await import('./dispatch-integration-event.js
24
25
  const workflows = {
25
26
  startRunFromTrigger: (...args: unknown[]) => runWorkflow(...args),
26
27
  deliverEventToJobListener: (...args: unknown[]) => deliverEventToListener(...args),
28
+ resolveWorkflowRunTriggerReference: (...args: unknown[]) =>
29
+ resolveWorkflowRunTriggerReference(...args),
27
30
  getStepLogContext: async () => ({harness: 'pi' as const}),
28
31
  getLeasedAgentToolContext: async () => ({
29
32
  workspaceId: crypto.randomUUID(),
@@ -35,6 +38,8 @@ describe('dispatchIntegrationEvent resilience to history-write failure', () => {
35
38
  beforeEach(() => {
36
39
  runWorkflow.mockReset();
37
40
  deliverEventToListener.mockReset();
41
+ resolveWorkflowRunTriggerReference.mockReset();
42
+ resolveWorkflowRunTriggerReference.mockResolvedValue(null);
38
43
  deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
39
44
  insertReceivedEvent.mockReset();
40
45
  insertReceivedEvent.mockRejectedValue(new Error('history db down'));
@@ -8,12 +8,15 @@ import {jobListenerSubscriptionFactory, triggerSubscriptionFactory} from '#test/
8
8
 
9
9
  const runWorkflow = vi.fn();
10
10
  const deliverEventToListener = vi.fn();
11
+ const resolveWorkflowRunTriggerReference = vi.fn();
11
12
 
12
13
  const {dispatchIntegrationEvent} = await import('./dispatch-integration-event.js');
13
14
 
14
15
  const workflows = {
15
16
  startRunFromTrigger: (...args: unknown[]) => runWorkflow(...args),
16
17
  deliverEventToJobListener: (...args: unknown[]) => deliverEventToListener(...args),
18
+ resolveWorkflowRunTriggerReference: (...args: unknown[]) =>
19
+ resolveWorkflowRunTriggerReference(...args),
17
20
  getStepLogContext: async () => ({harness: 'pi' as const}),
18
21
  getLeasedAgentToolContext: async () => ({
19
22
  workspaceId: crypto.randomUUID(),
@@ -84,8 +87,10 @@ describe('dispatchIntegrationEvent', () => {
84
87
  beforeEach(() => {
85
88
  runWorkflow.mockReset();
86
89
  deliverEventToListener.mockReset();
90
+ resolveWorkflowRunTriggerReference.mockReset();
87
91
  runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'Build and test'});
88
92
  deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
93
+ resolveWorkflowRunTriggerReference.mockResolvedValue(null);
89
94
  });
90
95
 
91
96
  test('fires the workflow for each matching workspace subscription, regardless of project', async () => {
@@ -114,6 +119,7 @@ describe('dispatchIntegrationEvent', () => {
114
119
 
115
120
  test('passes the source, event, deliveryId and raw payload through as the trigger payload', async () => {
116
121
  const workspaceId = crypto.randomUUID();
122
+ const connectionId = crypto.randomUUID();
117
123
  const deliveryId = crypto.randomUUID();
118
124
  const payload = {ref: 'refs/heads/feature', headCommitSha: 'deadbeef'};
119
125
  await triggerSubscriptionFactory.create({
@@ -123,10 +129,11 @@ describe('dispatchIntegrationEvent', () => {
123
129
  config: {},
124
130
  });
125
131
 
126
- await dispatch({workspaceId, deliveryId, payload});
132
+ await dispatch({workspaceId, connectionId, deliveryId, payload});
127
133
 
128
134
  expect(runWorkflow).toHaveBeenCalledWith(
129
135
  expect.objectContaining({
136
+ triggerConnectionId: connectionId,
130
137
  triggerPayload: {
131
138
  provider: 'github',
132
139
  source: 'github',
@@ -87,6 +87,7 @@ export async function dispatchIntegrationEvent(
87
87
  workspaceId: subscription.workspaceId,
88
88
  projectId: subscription.projectId,
89
89
  definitionId: subscription.workflowDefinitionId,
90
+ triggerConnectionId: params.connectionId,
90
91
  triggerPayload: {
91
92
  provider: params.provider,
92
93
  source: params.source,
@@ -116,6 +117,7 @@ export async function dispatchIntegrationEvent(
116
117
  history,
117
118
  eventRef: params.eventRef,
118
119
  workspaceId: params.workspaceId,
120
+ connectionId: params.connectionId,
119
121
  provider: params.provider,
120
122
  source: params.source,
121
123
  event: params.event,
@@ -4,6 +4,7 @@ import {jobListenerSubscriptionFactory} from '#test/index.js';
4
4
  import type {TriggerHistoryRecorder} from './record-trigger-history.js';
5
5
 
6
6
  const deliverEventToListener = vi.fn();
7
+ const resolveWorkflowRunTriggerReference = vi.fn();
7
8
  const findMatchingJobListenerSubscriptions = vi.fn();
8
9
  const listenerTriggered = vi.fn();
9
10
  const listenerFilterErrored = vi.fn();
@@ -33,11 +34,14 @@ const {routeEventToJobListeners} = await import('./route-event-to-job-listeners.
33
34
 
34
35
  const workflows = {
35
36
  deliverEventToJobListener: (...args: unknown[]) => deliverEventToListener(...args),
37
+ resolveWorkflowRunTriggerReference: (...args: unknown[]) =>
38
+ resolveWorkflowRunTriggerReference(...args),
36
39
  } as never;
37
40
 
38
41
  interface RouteOverrides {
39
42
  eventRef?: string;
40
43
  workspaceId?: string;
44
+ connectionId?: string;
41
45
  source?: string;
42
46
  event?: string;
43
47
  payload?: unknown;
@@ -49,6 +53,7 @@ function route(overrides: RouteOverrides = {}) {
49
53
  history: buildHistory(),
50
54
  eventRef: overrides.eventRef ?? crypto.randomUUID(),
51
55
  workspaceId: overrides.workspaceId ?? crypto.randomUUID(),
56
+ connectionId: overrides.connectionId ?? crypto.randomUUID(),
52
57
  provider: 'github',
53
58
  source: overrides.source ?? 'github',
54
59
  event: overrides.event ?? 'pull_request_review',
@@ -76,12 +81,14 @@ function buildHistory(): TriggerHistoryRecorder {
76
81
  describe('routeEventToJobListeners', () => {
77
82
  beforeEach(() => {
78
83
  deliverEventToListener.mockReset();
84
+ resolveWorkflowRunTriggerReference.mockReset();
79
85
  findMatchingJobListenerSubscriptions.mockReset();
80
86
  listenerTriggered.mockReset();
81
87
  listenerFilterErrored.mockReset();
82
88
  listenerDispatchErrored.mockReset();
83
89
  loggerWarn.mockReset();
84
90
  deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
91
+ resolveWorkflowRunTriggerReference.mockResolvedValue(null);
85
92
  });
86
93
 
87
94
  it('computes resolve as the effective disposition when on and until match one job', async () => {
@@ -121,6 +128,72 @@ describe('routeEventToJobListeners', () => {
121
128
  });
122
129
  });
123
130
 
131
+ it('passes the source connection to listener delivery', async () => {
132
+ const workspaceId = crypto.randomUUID();
133
+ const connectionId = crypto.randomUUID();
134
+ const jobId = crypto.randomUUID();
135
+ await jobListenerSubscriptionFactory.create({
136
+ workspaceId,
137
+ jobId,
138
+ kind: 'on',
139
+ matcherOrdinal: 0,
140
+ source: 'github',
141
+ event: 'pull_request_review',
142
+ });
143
+
144
+ await route({workspaceId, connectionId});
145
+
146
+ expect(deliverEventToListener).toHaveBeenCalledWith(
147
+ expect.objectContaining({jobId, triggerConnectionId: connectionId}),
148
+ );
149
+ });
150
+
151
+ it('resolves one trigger reference for all fire deliveries', async () => {
152
+ const workspaceId = crypto.randomUUID();
153
+ const connectionId = crypto.randomUUID();
154
+ const triggerReference = {
155
+ project: {id: crypto.randomUUID()},
156
+ repository: 'acme/api',
157
+ ref: 'refs/heads/main',
158
+ commit: 'a'.repeat(40),
159
+ };
160
+ for (const jobId of [crypto.randomUUID(), crypto.randomUUID()]) {
161
+ await jobListenerSubscriptionFactory.create({
162
+ workspaceId,
163
+ jobId,
164
+ kind: 'on',
165
+ matcherOrdinal: 0,
166
+ source: 'github',
167
+ event: 'pull_request_review',
168
+ });
169
+ }
170
+ resolveWorkflowRunTriggerReference.mockResolvedValue(triggerReference);
171
+
172
+ await route({workspaceId, connectionId});
173
+
174
+ expect(resolveWorkflowRunTriggerReference).toHaveBeenCalledTimes(1);
175
+ expect(resolveWorkflowRunTriggerReference).toHaveBeenCalledWith({
176
+ workspaceId,
177
+ triggerConnectionId: connectionId,
178
+ triggerPayload: {
179
+ provider: 'github',
180
+ source: 'github',
181
+ event: 'pull_request_review',
182
+ deliveryId: expect.any(String),
183
+ data: {action: 'submitted'},
184
+ },
185
+ });
186
+ expect(deliverEventToListener).toHaveBeenCalledTimes(2);
187
+ expect(deliverEventToListener).toHaveBeenNthCalledWith(
188
+ 1,
189
+ expect.objectContaining({triggerReference}),
190
+ );
191
+ expect(deliverEventToListener).toHaveBeenNthCalledWith(
192
+ 2,
193
+ expect.objectContaining({triggerReference}),
194
+ );
195
+ });
196
+
124
197
  it('does not deliver when no listener subscription matches', async () => {
125
198
  const workspaceId = crypto.randomUUID();
126
199
  await jobListenerSubscriptionFactory.create({
@@ -266,6 +339,32 @@ describe('routeEventToJobListeners', () => {
266
339
  expect(result).toMatchObject({engagedCount: 0, matchedJobCount: 0, acceptedJobCount: 0});
267
340
  });
268
341
 
342
+ it.each([
343
+ 'on',
344
+ 'until',
345
+ ] as const)('fails closed when a %s listener snapshot contains a foreign root', async (kind) => {
346
+ const workspaceId = crypto.randomUUID();
347
+ await jobListenerSubscriptionFactory.create({
348
+ workspaceId,
349
+ kind,
350
+ source: 'github',
351
+ event: 'pull_request_review',
352
+ config: {
353
+ filter: 'executions.size() > 0',
354
+ filter_snapshot: {executions: [{status: 'succeeded'}]},
355
+ },
356
+ });
357
+
358
+ const result = await route({workspaceId});
359
+
360
+ expect(deliverEventToListener).not.toHaveBeenCalled();
361
+ expect(listenerFilterErrored).toHaveBeenCalledWith(
362
+ expect.objectContaining({kind}),
363
+ 'Listener filter evaluation failed',
364
+ );
365
+ expect(result).toMatchObject({engagedCount: 1, matchedJobCount: 0, acceptedJobCount: 0});
366
+ });
367
+
269
368
  it.each([
270
369
  {name: 'missing jobs snapshot', filterSnapshot: {}},
271
370
  {name: 'empty jobs snapshot', filterSnapshot: {jobs: {}}},
@@ -1,3 +1,4 @@
1
+ import {projectWorkflowPredicateContext} from '@shipfox/expression';
1
2
  import {logger} from '@shipfox/node-opentelemetry';
2
3
  import {findMatchingJobListenerSubscriptions} from '#db/job-listener-subscriptions.js';
3
4
  import {evaluateStoredFilter, type StoredFilterEvaluation} from './config.js';
@@ -13,6 +14,7 @@ export interface RouteEventToJobListenersParams {
13
14
  history: TriggerHistoryRecorder;
14
15
  eventRef: string;
15
16
  workspaceId: string;
17
+ connectionId: string;
16
18
  provider: string;
17
19
  source: string;
18
20
  event: string;
@@ -65,6 +67,23 @@ export async function routeEventToJobListeners(
65
67
  }
66
68
  }
67
69
 
70
+ const fireDeliveryNeeded = [...effectiveMatchByJobId.values()].some(
71
+ (subscription) => listenerDisposition(subscription) === 'fire',
72
+ );
73
+ const triggerReference = fireDeliveryNeeded
74
+ ? await params.workflows.resolveWorkflowRunTriggerReference({
75
+ workspaceId: params.workspaceId,
76
+ triggerConnectionId: params.connectionId,
77
+ triggerPayload: {
78
+ provider: params.provider,
79
+ source: params.source,
80
+ event: params.event,
81
+ deliveryId: params.deliveryId,
82
+ data: params.payload,
83
+ },
84
+ })
85
+ : null;
86
+
68
87
  let acceptedJobCount = 0;
69
88
  let deliveredCount = 0;
70
89
  let sawTransientError = false;
@@ -82,6 +101,8 @@ export async function routeEventToJobListeners(
82
101
  source: params.source,
83
102
  event: params.event,
84
103
  provider: params.provider,
104
+ triggerConnectionId: params.connectionId,
105
+ triggerReference,
85
106
  payload: params.payload,
86
107
  receivedAt: params.receivedAt.toISOString(),
87
108
  });
@@ -121,7 +142,10 @@ function evaluateListenerFilter(params: EvaluateListenerFilterParams): StoredFil
121
142
  if (typeof filter !== 'string' || filter.trim() === '') {
122
143
  return evaluateStoredFilter({
123
144
  value: filter,
124
- context: {event: params.payload},
145
+ context: projectWorkflowPredicateContext(
146
+ params.subscription.kind === 'on' ? 'listener.on' : 'listener.until',
147
+ {event: params.payload},
148
+ ),
125
149
  invalidReason: 'Listener subscription filter must be a non-empty string when set',
126
150
  evaluationFailedReason: 'Listener filter evaluation failed',
127
151
  });
@@ -132,7 +156,10 @@ function evaluateListenerFilter(params: EvaluateListenerFilterParams): StoredFil
132
156
 
133
157
  return evaluateStoredFilter({
134
158
  value: filter,
135
- context: {...snapshot.value, event: params.payload},
159
+ context: projectWorkflowPredicateContext(
160
+ params.subscription.kind === 'on' ? 'listener.on' : 'listener.until',
161
+ {...snapshot.value, event: params.payload},
162
+ ),
136
163
  invalidReason: 'Listener subscription filter must be a non-empty string when set',
137
164
  evaluationFailedReason: 'Listener filter evaluation failed',
138
165
  });
@@ -52,6 +52,7 @@ function localWorkflowsClient(): WorkflowsModuleClient {
52
52
  {definitionId},
53
53
  );
54
54
  },
55
+ resolveWorkflowRunTriggerReference: () => null,
55
56
  deliverEventToJobListener: ({jobId}) => {
56
57
  if (jobId === listenerErrorInput.jobId) {
57
58
  throw createInterModuleKnownError(
@@ -92,6 +93,13 @@ async function runConsumerSuite(client: WorkflowsModuleClient): Promise<void> {
92
93
  buffered: true,
93
94
  skipped: false,
94
95
  });
96
+ await expect(
97
+ client.resolveWorkflowRunTriggerReference({
98
+ workspaceId: input.workspaceId,
99
+ triggerConnectionId: crypto.randomUUID(),
100
+ triggerPayload: input.triggerPayload,
101
+ }),
102
+ ).resolves.toBeNull();
95
103
  await expect(client.deliverEventToJobListener(listenerErrorInput)).rejects.toMatchObject({
96
104
  code: 'workspace-not-found',
97
105
  details: {workspaceId: input.workspaceId},