@shipfox/api-triggers 13.0.0 → 14.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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +34 -0
- package/README.md +14 -10
- package/dist/core/entities/decision.d.ts +3 -2
- package/dist/core/entities/decision.d.ts.map +1 -1
- package/dist/core/entities/decision.js.map +1 -1
- package/dist/core/entities/job-listener-subscription.d.ts +2 -1
- package/dist/core/entities/job-listener-subscription.d.ts.map +1 -1
- package/dist/core/entities/job-listener-subscription.js.map +1 -1
- package/dist/core/entities/received-event.d.ts +14 -1
- package/dist/core/entities/received-event.d.ts.map +1 -1
- package/dist/core/entities/received-event.js +2 -1
- package/dist/core/entities/received-event.js.map +1 -1
- package/dist/core/entities/subscription.d.ts +2 -1
- package/dist/core/entities/subscription.d.ts.map +1 -1
- package/dist/core/entities/subscription.js.map +1 -1
- package/dist/core/fire-cron.d.ts.map +1 -1
- package/dist/core/fire-cron.js +3 -1
- package/dist/core/fire-cron.js.map +1 -1
- package/dist/core/fire-manual.d.ts.map +1 -1
- package/dist/core/fire-manual.js +3 -1
- package/dist/core/fire-manual.js.map +1 -1
- package/dist/core/record-trigger-history.d.ts +2 -0
- package/dist/core/record-trigger-history.d.ts.map +1 -1
- package/dist/core/record-trigger-history.js +13 -1
- package/dist/core/record-trigger-history.js.map +1 -1
- package/dist/core/route-event-to-job-listeners.js +2 -0
- package/dist/core/route-event-to-job-listeners.js.map +1 -1
- package/dist/db/db.d.ts +48 -14
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/event-history.d.ts +17 -0
- package/dist/db/event-history.d.ts.map +1 -1
- package/dist/db/event-history.js +45 -1
- package/dist/db/event-history.js.map +1 -1
- package/dist/db/event-queries.d.ts +5 -1
- package/dist/db/event-queries.d.ts.map +1 -1
- package/dist/db/event-queries.js +30 -4
- package/dist/db/event-queries.js.map +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/job-listener-subscriptions.d.ts +1 -1
- package/dist/db/job-listener-subscriptions.d.ts.map +1 -1
- package/dist/db/job-listener-subscriptions.js +10 -3
- package/dist/db/job-listener-subscriptions.js.map +1 -1
- package/dist/db/schema/decisions.d.ts +3 -3
- package/dist/db/schema/decisions.d.ts.map +1 -1
- package/dist/db/schema/decisions.js +8 -3
- package/dist/db/schema/decisions.js.map +1 -1
- package/dist/db/schema/job-listener-subscriptions.d.ts +1 -1
- package/dist/db/schema/job-listener-subscriptions.d.ts.map +1 -1
- package/dist/db/schema/job-listener-subscriptions.js +2 -1
- package/dist/db/schema/job-listener-subscriptions.js.map +1 -1
- package/dist/db/schema/received-events.d.ts +38 -4
- package/dist/db/schema/received-events.d.ts.map +1 -1
- package/dist/db/schema/received-events.js +11 -2
- package/dist/db/schema/received-events.js.map +1 -1
- package/dist/db/schema/subscriptions.d.ts +1 -1
- package/dist/db/schema/subscriptions.d.ts.map +1 -1
- package/dist/db/schema/subscriptions.js +2 -1
- package/dist/db/schema/subscriptions.js.map +1 -1
- package/dist/db/subscription-event.d.ts +7 -0
- package/dist/db/subscription-event.d.ts.map +1 -0
- package/dist/db/subscription-event.js +19 -0
- package/dist/db/subscription-event.js.map +1 -0
- package/dist/db/subscriptions.d.ts +5 -1
- package/dist/db/subscriptions.d.ts.map +1 -1
- package/dist/db/subscriptions.js +10 -3
- package/dist/db/subscriptions.js.map +1 -1
- package/dist/presentation/dto/trigger-events.d.ts +3 -2
- package/dist/presentation/dto/trigger-events.d.ts.map +1 -1
- package/dist/presentation/dto/trigger-events.js +9 -0
- package/dist/presentation/dto/trigger-events.js.map +1 -1
- package/dist/presentation/routes/get-trigger-event.d.ts.map +1 -1
- package/dist/presentation/routes/get-trigger-event.js +8 -4
- package/dist/presentation/routes/get-trigger-event.js.map +1 -1
- package/dist/presentation/routes/list-trigger-event-facets.js +1 -1
- package/dist/presentation/routes/list-trigger-event-facets.js.map +1 -1
- package/dist/presentation/routes/list-trigger-events.d.ts.map +1 -1
- package/dist/presentation/routes/list-trigger-events.js +3 -1
- package/dist/presentation/routes/list-trigger-events.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_nullable_subscription_events.sql +2 -0
- package/drizzle/0002_dev_trigger_journal.sql +7 -0
- package/drizzle/0003_add_origin_facet_index.sql +1 -0
- package/drizzle/meta/0001_snapshot.json +905 -0
- package/drizzle/meta/0002_snapshot.json +947 -0
- package/drizzle/meta/0003_snapshot.json +968 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +7 -7
- package/src/core/dispatch-integration-event.test.ts +88 -0
- package/src/core/entities/decision.ts +3 -2
- package/src/core/entities/job-listener-subscription.ts +2 -1
- package/src/core/entities/received-event.ts +15 -1
- package/src/core/entities/subscription.ts +2 -1
- package/src/core/fire-cron.test.ts +20 -0
- package/src/core/fire-cron.ts +3 -1
- package/src/core/fire-manual.test.ts +25 -0
- package/src/core/fire-manual.ts +3 -1
- package/src/core/record-trigger-history.test.ts +78 -0
- package/src/core/record-trigger-history.ts +19 -0
- package/src/core/route-event-to-job-listeners.test.ts +47 -0
- package/src/core/route-event-to-job-listeners.ts +2 -0
- package/src/db/event-history.test.ts +123 -0
- package/src/db/event-history.ts +84 -1
- package/src/db/event-queries.ts +56 -3
- package/src/db/index.ts +1 -0
- package/src/db/job-listener-subscriptions.test.ts +133 -1
- package/src/db/job-listener-subscriptions.ts +5 -4
- package/src/db/schema/decisions.test.ts +93 -4
- package/src/db/schema/decisions.ts +12 -3
- package/src/db/schema/job-listener-subscriptions.ts +2 -1
- package/src/db/schema/received-events.test.ts +45 -0
- package/src/db/schema/received-events.ts +12 -1
- package/src/db/schema/subscriptions.ts +2 -1
- package/src/db/subscription-event.ts +24 -0
- package/src/db/subscriptions.test.ts +137 -0
- package/src/db/subscriptions.ts +9 -4
- package/src/presentation/dto/trigger-events.test.ts +193 -0
- package/src/presentation/dto/trigger-events.ts +12 -0
- package/src/presentation/routes/get-trigger-event.test.ts +179 -0
- package/src/presentation/routes/get-trigger-event.ts +15 -3
- package/src/presentation/routes/list-trigger-event-facets.test.ts +39 -2
- package/src/presentation/routes/list-trigger-event-facets.ts +1 -1
- package/src/presentation/routes/list-trigger-events.test.ts +99 -0
- package/src/presentation/routes/list-trigger-events.ts +4 -0
- package/test/factories/received-event.ts +2 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -12,7 +12,8 @@ export const triggerSubscriptions = pgTable(
|
|
|
12
12
|
workflowDefinitionId: uuid('workflow_definition_id').notNull(),
|
|
13
13
|
name: text('name').notNull(),
|
|
14
14
|
source: text('source').notNull(),
|
|
15
|
-
|
|
15
|
+
// NULL is a source subscription: matches every event the source delivers.
|
|
16
|
+
event: text('event'),
|
|
16
17
|
config: jsonb('config').notNull(),
|
|
17
18
|
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
18
19
|
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {eq, isNull, or} from 'drizzle-orm';
|
|
2
|
+
import type {PgColumn} from 'drizzle-orm/pg-core';
|
|
3
|
+
|
|
4
|
+
export function normalizeSubscriptionEvent(params: {
|
|
5
|
+
source: string;
|
|
6
|
+
event: string | null | undefined;
|
|
7
|
+
}): string | null {
|
|
8
|
+
if (params.event === undefined || params.event === null) {
|
|
9
|
+
if (params.source === 'manual' || params.source === 'cron') {
|
|
10
|
+
throw new Error(`A ${params.source} subscription requires an event`);
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const normalizedEvent = params.event.trim();
|
|
16
|
+
if (normalizedEvent === '') {
|
|
17
|
+
throw new Error(`A ${params.source} subscription event cannot be blank`);
|
|
18
|
+
}
|
|
19
|
+
return normalizedEvent;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function subscriptionEventCondition(column: PgColumn, event: string) {
|
|
23
|
+
return or(eq(column, event), isNull(column));
|
|
24
|
+
}
|
|
@@ -55,6 +55,80 @@ describe('projectDefinitionTriggers', () => {
|
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
test('stores NULL for a trigger with no event', async () => {
|
|
59
|
+
await projectDefinitionTriggers({
|
|
60
|
+
workspaceId,
|
|
61
|
+
projectId,
|
|
62
|
+
workflowDefinitionId,
|
|
63
|
+
triggers: {
|
|
64
|
+
on_any: {source: 'github'},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const rows = await db()
|
|
69
|
+
.select()
|
|
70
|
+
.from(triggerSubscriptions)
|
|
71
|
+
.where(eq(triggerSubscriptions.workflowDefinitionId, workflowDefinitionId));
|
|
72
|
+
|
|
73
|
+
expect(rows).toHaveLength(1);
|
|
74
|
+
expect(rows[0]?.source).toBe('github');
|
|
75
|
+
expect(rows[0]?.event).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test.each(['', ' '])('rejects a blank integration trigger event %j', async (event) => {
|
|
79
|
+
await expect(
|
|
80
|
+
projectDefinitionTriggers({
|
|
81
|
+
workspaceId,
|
|
82
|
+
projectId,
|
|
83
|
+
workflowDefinitionId,
|
|
84
|
+
triggers: {
|
|
85
|
+
invalid: {source: 'github', event},
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
).rejects.toThrow('A github subscription event cannot be blank');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test.each(['manual', 'cron'] as const)('rejects a blank %s trigger event', async (source) => {
|
|
92
|
+
await expect(
|
|
93
|
+
projectDefinitionTriggers({
|
|
94
|
+
workspaceId,
|
|
95
|
+
projectId,
|
|
96
|
+
workflowDefinitionId,
|
|
97
|
+
triggers: {
|
|
98
|
+
invalid: {source},
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
).rejects.toThrow(`A ${source} subscription requires an event`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('clears an exact event to NULL when a trigger drops its event across reconciliations', async () => {
|
|
105
|
+
await projectDefinitionTriggers({
|
|
106
|
+
workspaceId,
|
|
107
|
+
projectId,
|
|
108
|
+
workflowDefinitionId,
|
|
109
|
+
triggers: {
|
|
110
|
+
on_push: {source: 'github', event: 'push'},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await projectDefinitionTriggers({
|
|
115
|
+
workspaceId,
|
|
116
|
+
projectId,
|
|
117
|
+
workflowDefinitionId,
|
|
118
|
+
triggers: {
|
|
119
|
+
on_push: {source: 'github'},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const rows = await db()
|
|
124
|
+
.select()
|
|
125
|
+
.from(triggerSubscriptions)
|
|
126
|
+
.where(eq(triggerSubscriptions.workflowDefinitionId, workflowDefinitionId));
|
|
127
|
+
|
|
128
|
+
expect(rows).toHaveLength(1);
|
|
129
|
+
expect(rows[0]?.event).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
58
132
|
test('removes rows whose trigger name is no longer in the map', async () => {
|
|
59
133
|
await projectDefinitionTriggers({
|
|
60
134
|
workspaceId,
|
|
@@ -196,6 +270,69 @@ describe('findMatchingSubscriptions', () => {
|
|
|
196
270
|
expect(matches[0]?.name).toBe('on_push');
|
|
197
271
|
});
|
|
198
272
|
|
|
273
|
+
test('a NULL event subscription matches any event from its source and none from another source', async () => {
|
|
274
|
+
await projectDefinitionTriggers({
|
|
275
|
+
workspaceId,
|
|
276
|
+
projectId,
|
|
277
|
+
workflowDefinitionId: crypto.randomUUID(),
|
|
278
|
+
triggers: {
|
|
279
|
+
on_any: {source: 'github'},
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const pushMatches = await findMatchingSubscriptions({
|
|
284
|
+
workspaceId,
|
|
285
|
+
source: 'github',
|
|
286
|
+
event: 'push',
|
|
287
|
+
});
|
|
288
|
+
const prMatches = await findMatchingSubscriptions({
|
|
289
|
+
workspaceId,
|
|
290
|
+
source: 'github',
|
|
291
|
+
event: 'pull_request',
|
|
292
|
+
});
|
|
293
|
+
const otherSourceMatches = await findMatchingSubscriptions({
|
|
294
|
+
workspaceId,
|
|
295
|
+
source: 'sentry',
|
|
296
|
+
event: 'push',
|
|
297
|
+
});
|
|
298
|
+
const otherWorkspaceMatches = await findMatchingSubscriptions({
|
|
299
|
+
workspaceId: crypto.randomUUID(),
|
|
300
|
+
source: 'github',
|
|
301
|
+
event: 'push',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(pushMatches.map((match) => match.name)).toEqual(['on_any']);
|
|
305
|
+
expect(prMatches.map((match) => match.name)).toEqual(['on_any']);
|
|
306
|
+
expect(otherSourceMatches).toHaveLength(0);
|
|
307
|
+
expect(otherWorkspaceMatches).toHaveLength(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('exact-event subscriptions are unaffected by NULL event rows', async () => {
|
|
311
|
+
await projectDefinitionTriggers({
|
|
312
|
+
workspaceId,
|
|
313
|
+
projectId,
|
|
314
|
+
workflowDefinitionId: crypto.randomUUID(),
|
|
315
|
+
triggers: {
|
|
316
|
+
on_any: {source: 'github'},
|
|
317
|
+
on_push: {source: 'github', event: 'push'},
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const pushMatches = await findMatchingSubscriptions({
|
|
322
|
+
workspaceId,
|
|
323
|
+
source: 'github',
|
|
324
|
+
event: 'push',
|
|
325
|
+
});
|
|
326
|
+
const prMatches = await findMatchingSubscriptions({
|
|
327
|
+
workspaceId,
|
|
328
|
+
source: 'github',
|
|
329
|
+
event: 'pull_request',
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(pushMatches.map((match) => match.name).sort()).toEqual(['on_any', 'on_push']);
|
|
333
|
+
expect(prMatches.map((match) => match.name)).toEqual(['on_any']);
|
|
334
|
+
});
|
|
335
|
+
|
|
199
336
|
test('matches connection-specific sources, not the provider id', async () => {
|
|
200
337
|
await projectDefinitionTriggers({
|
|
201
338
|
workspaceId,
|
package/src/db/subscriptions.ts
CHANGED
|
@@ -4,13 +4,18 @@ import type {TriggerSubscription} from '#core/entities/subscription.js';
|
|
|
4
4
|
import {deleteCronScheduleForSubscription, syncCronSchedule} from './cron-schedules.js';
|
|
5
5
|
import {db, type Executor, type Tx} from './db.js';
|
|
6
6
|
import {toTriggerSubscription, triggerSubscriptions} from './schema/subscriptions.js';
|
|
7
|
+
import {normalizeSubscriptionEvent, subscriptionEventCondition} from './subscription-event.js';
|
|
8
|
+
|
|
9
|
+
interface ProjectDefinitionTrigger extends Omit<TriggerDto, 'event'> {
|
|
10
|
+
event?: string | undefined;
|
|
11
|
+
}
|
|
7
12
|
|
|
8
13
|
export interface ProjectDefinitionTriggersParams {
|
|
9
14
|
tx?: Tx | undefined;
|
|
10
15
|
workspaceId: string;
|
|
11
16
|
projectId: string;
|
|
12
17
|
workflowDefinitionId: string;
|
|
13
|
-
triggers: Record<string,
|
|
18
|
+
triggers: Record<string, ProjectDefinitionTrigger>;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export async function projectDefinitionTriggers(
|
|
@@ -49,7 +54,7 @@ export async function projectDefinitionTriggers(
|
|
|
49
54
|
workflowDefinitionId: params.workflowDefinitionId,
|
|
50
55
|
name,
|
|
51
56
|
source: trigger.source,
|
|
52
|
-
event: trigger.event,
|
|
57
|
+
event: normalizeSubscriptionEvent({source: trigger.source, event: trigger.event}),
|
|
53
58
|
config,
|
|
54
59
|
})
|
|
55
60
|
.onConflictDoUpdate({
|
|
@@ -58,7 +63,7 @@ export async function projectDefinitionTriggers(
|
|
|
58
63
|
workspaceId: params.workspaceId,
|
|
59
64
|
projectId: params.projectId,
|
|
60
65
|
source: trigger.source,
|
|
61
|
-
event: trigger.event,
|
|
66
|
+
event: normalizeSubscriptionEvent({source: trigger.source, event: trigger.event}),
|
|
62
67
|
config,
|
|
63
68
|
updatedAt: new Date(),
|
|
64
69
|
},
|
|
@@ -161,7 +166,7 @@ export async function findMatchingSubscriptions(
|
|
|
161
166
|
and(
|
|
162
167
|
eq(triggerSubscriptions.workspaceId, params.workspaceId),
|
|
163
168
|
eq(triggerSubscriptions.source, params.source),
|
|
164
|
-
|
|
169
|
+
subscriptionEventCondition(triggerSubscriptions.event, params.event),
|
|
165
170
|
),
|
|
166
171
|
);
|
|
167
172
|
return rows.map(toTriggerSubscription);
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
triggerDecisionDtoSchema,
|
|
3
|
+
triggerDecisionSubscriptionKindSchema,
|
|
4
|
+
triggerEventDetailResponseSchema,
|
|
5
|
+
triggerEventFacetsResponseSchema,
|
|
6
|
+
triggerEventListQuerySchema,
|
|
7
|
+
triggerEventOriginSchema,
|
|
8
|
+
triggerEventReplayDtoSchema,
|
|
9
|
+
} from '@shipfox/api-triggers-dto';
|
|
1
10
|
import type {TriggerDecision} from '#core/entities/decision.js';
|
|
2
11
|
import type {
|
|
3
12
|
TriggerReceivedEvent,
|
|
@@ -7,6 +16,7 @@ import {
|
|
|
7
16
|
toTriggerDecisionDto,
|
|
8
17
|
toTriggerEventDto,
|
|
9
18
|
toTriggerEventListItemDto,
|
|
19
|
+
toTriggerEventReplayDto,
|
|
10
20
|
} from './trigger-events.js';
|
|
11
21
|
|
|
12
22
|
const baseSummary: TriggerReceivedEventSummary = {
|
|
@@ -17,6 +27,7 @@ const baseSummary: TriggerReceivedEventSummary = {
|
|
|
17
27
|
provider: null,
|
|
18
28
|
source: 'manual',
|
|
19
29
|
event: 'fire',
|
|
30
|
+
replayOfEventId: null,
|
|
20
31
|
deliveryId: null,
|
|
21
32
|
connectionId: null,
|
|
22
33
|
connectionName: null,
|
|
@@ -39,6 +50,7 @@ describe('trigger-events mappers', () => {
|
|
|
39
50
|
provider: null,
|
|
40
51
|
source: 'manual',
|
|
41
52
|
event: 'fire',
|
|
53
|
+
replay_of_event_id: null,
|
|
42
54
|
delivery_id: null,
|
|
43
55
|
connection_id: null,
|
|
44
56
|
outcome: 'discarded',
|
|
@@ -50,6 +62,13 @@ describe('trigger-events mappers', () => {
|
|
|
50
62
|
expect(dto).not.toHaveProperty('payload');
|
|
51
63
|
});
|
|
52
64
|
|
|
65
|
+
test('toTriggerEventListItemDto maps replay_of_event_id', () => {
|
|
66
|
+
const replayOfEventId = '99999999-9999-4999-8999-999999999999';
|
|
67
|
+
const dto = toTriggerEventListItemDto({...baseSummary, replayOfEventId});
|
|
68
|
+
|
|
69
|
+
expect(dto.replay_of_event_id).toBe(replayOfEventId);
|
|
70
|
+
});
|
|
71
|
+
|
|
53
72
|
test('toTriggerEventDto carries the payload (including null)', () => {
|
|
54
73
|
const withPayload: TriggerReceivedEvent = {...baseSummary, payload: {ref: 'main'}};
|
|
55
74
|
const withoutPayload: TriggerReceivedEvent = {...baseSummary, payload: null};
|
|
@@ -141,4 +160,178 @@ describe('trigger-events mappers', () => {
|
|
|
141
160
|
decision: 'triggered',
|
|
142
161
|
});
|
|
143
162
|
});
|
|
163
|
+
|
|
164
|
+
test('toTriggerEventReplayDto maps null run ids and ISO dates', () => {
|
|
165
|
+
expect(
|
|
166
|
+
toTriggerEventReplayDto({
|
|
167
|
+
id: '99999999-9999-4999-8999-999999999999',
|
|
168
|
+
receivedAt: new Date('2026-05-07T00:00:02.000Z'),
|
|
169
|
+
outcome: 'routed',
|
|
170
|
+
runId: '77777777-7777-4777-8777-777777777777',
|
|
171
|
+
}),
|
|
172
|
+
).toEqual({
|
|
173
|
+
id: '99999999-9999-4999-8999-999999999999',
|
|
174
|
+
received_at: '2026-05-07T00:00:02.000Z',
|
|
175
|
+
outcome: 'routed',
|
|
176
|
+
run_id: '77777777-7777-4777-8777-777777777777',
|
|
177
|
+
});
|
|
178
|
+
expect(
|
|
179
|
+
toTriggerEventReplayDto({
|
|
180
|
+
id: '99999999-9999-4999-8999-999999999999',
|
|
181
|
+
receivedAt: new Date('2026-05-07T00:00:02.000Z'),
|
|
182
|
+
outcome: 'discarded',
|
|
183
|
+
runId: null,
|
|
184
|
+
}).run_id,
|
|
185
|
+
).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('trigger event DTO contract', () => {
|
|
190
|
+
const baseDecision = {
|
|
191
|
+
id: '33333333-3333-4333-8333-333333333333',
|
|
192
|
+
received_event_id: '11111111-1111-4111-8111-111111111111',
|
|
193
|
+
subscription_kind: 'trigger',
|
|
194
|
+
subscription_id: '44444444-4444-4444-4444-444444444444',
|
|
195
|
+
subscription_name: 'Deploy production',
|
|
196
|
+
workflow_definition_id: '55555555-5555-5555-8555-555555555555',
|
|
197
|
+
project_id: '66666666-6666-6666-8666-666666666666',
|
|
198
|
+
workflow_run_id: null,
|
|
199
|
+
job_id: null,
|
|
200
|
+
matcher_kind: null,
|
|
201
|
+
matcher_ordinal: null,
|
|
202
|
+
decision: 'triggered',
|
|
203
|
+
run_id: '77777777-7777-4777-8777-777777777777',
|
|
204
|
+
run_name: 'Dev run',
|
|
205
|
+
reason: null,
|
|
206
|
+
created_at: '2026-05-07T00:00:02.000Z',
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
test('accepts dev origins and decisions with a null subscription id', () => {
|
|
210
|
+
expect(triggerEventOriginSchema.safeParse('dev').success).toBe(true);
|
|
211
|
+
expect(triggerDecisionSubscriptionKindSchema.safeParse('dev').success).toBe(true);
|
|
212
|
+
expect(
|
|
213
|
+
triggerDecisionDtoSchema.safeParse({
|
|
214
|
+
...baseDecision,
|
|
215
|
+
subscription_kind: 'dev',
|
|
216
|
+
subscription_id: null,
|
|
217
|
+
}).success,
|
|
218
|
+
).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('requires subscription ids for trigger and listener decisions', () => {
|
|
222
|
+
for (const subscription_kind of ['trigger', 'listener'] as const) {
|
|
223
|
+
expect(
|
|
224
|
+
triggerDecisionDtoSchema.safeParse({
|
|
225
|
+
...baseDecision,
|
|
226
|
+
subscription_kind,
|
|
227
|
+
subscription_id: null,
|
|
228
|
+
}).success,
|
|
229
|
+
).toBe(false);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('requires dev decisions to omit a subscription id', () => {
|
|
234
|
+
expect(
|
|
235
|
+
triggerDecisionDtoSchema.safeParse({
|
|
236
|
+
...baseDecision,
|
|
237
|
+
subscription_kind: 'dev',
|
|
238
|
+
subscription_id: baseDecision.subscription_id,
|
|
239
|
+
}).success,
|
|
240
|
+
).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('rejects unknown origin and decision kinds', () => {
|
|
244
|
+
expect(triggerEventOriginSchema.safeParse('unknown').success).toBe(false);
|
|
245
|
+
expect(triggerDecisionSubscriptionKindSchema.safeParse('unknown').success).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const baseQuery = {
|
|
249
|
+
workspace_id: '22222222-2222-4222-8222-222222222222',
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
test('list query accepts origin as a single, repeated, or comma-separated filter', () => {
|
|
253
|
+
expect(triggerEventListQuerySchema.parse({...baseQuery, origin: 'dev'}).origin).toEqual([
|
|
254
|
+
'dev',
|
|
255
|
+
]);
|
|
256
|
+
expect(
|
|
257
|
+
triggerEventListQuerySchema.parse({...baseQuery, origin: ['dev', 'manual']}).origin,
|
|
258
|
+
).toEqual(['dev', 'manual']);
|
|
259
|
+
expect(triggerEventListQuerySchema.parse({...baseQuery, origin: 'dev,manual'}).origin).toEqual([
|
|
260
|
+
'dev',
|
|
261
|
+
'manual',
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('list query rejects an unknown origin filter value', () => {
|
|
266
|
+
expect(triggerEventListQuerySchema.safeParse({...baseQuery, origin: 'bogus'}).success).toBe(
|
|
267
|
+
false,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('list query accepts replayable=true only', () => {
|
|
272
|
+
expect(triggerEventListQuerySchema.parse({...baseQuery, replayable: 'true'})).toMatchObject({
|
|
273
|
+
replayable: true,
|
|
274
|
+
});
|
|
275
|
+
expect(triggerEventListQuerySchema.parse(baseQuery).replayable).toBeUndefined();
|
|
276
|
+
expect(triggerEventListQuerySchema.safeParse({...baseQuery, replayable: 'false'}).success).toBe(
|
|
277
|
+
false,
|
|
278
|
+
);
|
|
279
|
+
expect(triggerEventListQuerySchema.safeParse({...baseQuery, replayable: 'yes'}).success).toBe(
|
|
280
|
+
false,
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('detail response accepts replay_of_event_id and a replays list', () => {
|
|
285
|
+
const detail = {
|
|
286
|
+
id: '11111111-1111-4111-8111-111111111111',
|
|
287
|
+
event_ref: 'event-ref',
|
|
288
|
+
origin: 'dev',
|
|
289
|
+
workspace_id: '22222222-2222-4222-8222-222222222222',
|
|
290
|
+
provider: null,
|
|
291
|
+
source: 'github',
|
|
292
|
+
event: 'push',
|
|
293
|
+
replay_of_event_id: '99999999-9999-4999-8999-999999999999',
|
|
294
|
+
delivery_id: null,
|
|
295
|
+
connection_id: null,
|
|
296
|
+
connection_name: null,
|
|
297
|
+
outcome: 'routed',
|
|
298
|
+
matched_count: 1,
|
|
299
|
+
payload: {ref: 'refs/heads/main'},
|
|
300
|
+
received_at: '2026-05-07T00:00:00.000Z',
|
|
301
|
+
processed_at: null,
|
|
302
|
+
created_at: '2026-05-07T00:00:01.000Z',
|
|
303
|
+
decisions: [],
|
|
304
|
+
replays: [
|
|
305
|
+
{
|
|
306
|
+
id: '99999999-9999-4999-8999-999999999999',
|
|
307
|
+
received_at: '2026-05-07T00:00:02.000Z',
|
|
308
|
+
outcome: 'routed',
|
|
309
|
+
run_id: '77777777-7777-4777-8777-777777777777',
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const parsed = triggerEventDetailResponseSchema.parse(detail);
|
|
315
|
+
expect(parsed.replay_of_event_id).toBe(detail.replay_of_event_id);
|
|
316
|
+
expect(parsed.replays).toEqual(detail.replays);
|
|
317
|
+
expect(triggerEventReplayDtoSchema.safeParse(detail.replays[0]).success).toBe(true);
|
|
318
|
+
expect(
|
|
319
|
+
triggerEventReplayDtoSchema.safeParse({...detail.replays[0], run_id: null}).success,
|
|
320
|
+
).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('facets response accepts origins', () => {
|
|
324
|
+
const parsed = triggerEventFacetsResponseSchema.parse({
|
|
325
|
+
sources: [{value: 'github', count: 2}],
|
|
326
|
+
events: [{value: 'push', count: 2}],
|
|
327
|
+
origins: [
|
|
328
|
+
{value: 'integration', count: 2},
|
|
329
|
+
{value: 'dev', count: 1},
|
|
330
|
+
],
|
|
331
|
+
});
|
|
332
|
+
expect(parsed.origins).toEqual([
|
|
333
|
+
{value: 'integration', count: 2},
|
|
334
|
+
{value: 'dev', count: 1},
|
|
335
|
+
]);
|
|
336
|
+
});
|
|
144
337
|
});
|
|
@@ -2,9 +2,11 @@ import type {
|
|
|
2
2
|
TriggerDecisionDto,
|
|
3
3
|
TriggerEventDto,
|
|
4
4
|
TriggerEventListItemDto,
|
|
5
|
+
TriggerEventReplayDto,
|
|
5
6
|
} from '@shipfox/api-triggers-dto';
|
|
6
7
|
import type {TriggerDecision} from '#core/entities/decision.js';
|
|
7
8
|
import type {
|
|
9
|
+
TriggerEventReplay,
|
|
8
10
|
TriggerReceivedEvent,
|
|
9
11
|
TriggerReceivedEventSummary,
|
|
10
12
|
} from '#core/entities/received-event.js';
|
|
@@ -20,6 +22,7 @@ export function toTriggerEventListItemDto(
|
|
|
20
22
|
provider: event.provider,
|
|
21
23
|
source: event.source,
|
|
22
24
|
event: event.event,
|
|
25
|
+
replay_of_event_id: event.replayOfEventId,
|
|
23
26
|
delivery_id: event.deliveryId,
|
|
24
27
|
connection_id: event.connectionId,
|
|
25
28
|
outcome: event.outcome,
|
|
@@ -38,6 +41,15 @@ export function toTriggerEventDto(event: TriggerReceivedEvent): TriggerEventDto
|
|
|
38
41
|
};
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
export function toTriggerEventReplayDto(replay: TriggerEventReplay): TriggerEventReplayDto {
|
|
45
|
+
return {
|
|
46
|
+
id: replay.id,
|
|
47
|
+
received_at: replay.receivedAt.toISOString(),
|
|
48
|
+
outcome: replay.outcome,
|
|
49
|
+
run_id: replay.runId,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
export function toTriggerDecisionDto(decision: TriggerDecision): TriggerDecisionDto {
|
|
42
54
|
return {
|
|
43
55
|
id: decision.id,
|
|
@@ -151,6 +151,185 @@ describe('GET /trigger-events/:id', () => {
|
|
|
151
151
|
);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test('serializes a dev decision with a null subscription id', async () => {
|
|
155
|
+
const event = await receivedEventFactory.create({
|
|
156
|
+
workspaceId,
|
|
157
|
+
origin: 'dev',
|
|
158
|
+
source: 'dev',
|
|
159
|
+
event: 'replay',
|
|
160
|
+
outcome: 'routed',
|
|
161
|
+
matchedCount: 1,
|
|
162
|
+
});
|
|
163
|
+
await decisionFactory.create({
|
|
164
|
+
receivedEventId: event.id,
|
|
165
|
+
subscriptionKind: 'dev',
|
|
166
|
+
subscriptionId: null,
|
|
167
|
+
subscriptionName: 'on_issue',
|
|
168
|
+
workflowDefinitionId: crypto.randomUUID(),
|
|
169
|
+
projectId: null,
|
|
170
|
+
decision: 'filter-error',
|
|
171
|
+
runId: null,
|
|
172
|
+
runName: null,
|
|
173
|
+
reason: 'filter is false',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const res = await app.inject({method: 'GET', url: `/trigger-events/${event.id}`});
|
|
177
|
+
|
|
178
|
+
expect(res.statusCode).toBe(200);
|
|
179
|
+
expect(res.json()).toMatchObject({
|
|
180
|
+
origin: 'dev',
|
|
181
|
+
replay_of_event_id: null,
|
|
182
|
+
replays: [],
|
|
183
|
+
decisions: [
|
|
184
|
+
{
|
|
185
|
+
subscription_kind: 'dev',
|
|
186
|
+
subscription_id: null,
|
|
187
|
+
decision: 'filter-error',
|
|
188
|
+
reason: 'filter is false',
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('returns replay_of_event_id on a dev event that replayed a source event', async () => {
|
|
195
|
+
const source = await receivedEventFactory.create({workspaceId});
|
|
196
|
+
const replay = await receivedEventFactory.create({
|
|
197
|
+
workspaceId,
|
|
198
|
+
origin: 'dev',
|
|
199
|
+
source: 'github',
|
|
200
|
+
event: 'push',
|
|
201
|
+
replayOfEventId: source.id,
|
|
202
|
+
payload: {ref: 'refs/heads/main'},
|
|
203
|
+
});
|
|
204
|
+
await decisionFactory.create({
|
|
205
|
+
receivedEventId: replay.id,
|
|
206
|
+
subscriptionKind: 'dev',
|
|
207
|
+
subscriptionId: null,
|
|
208
|
+
subscriptionName: 'on_push',
|
|
209
|
+
workflowDefinitionId: crypto.randomUUID(),
|
|
210
|
+
projectId: null,
|
|
211
|
+
decision: 'triggered',
|
|
212
|
+
runId: crypto.randomUUID(),
|
|
213
|
+
runName: 'dev-replay',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const res = await app.inject({method: 'GET', url: `/trigger-events/${replay.id}`});
|
|
217
|
+
|
|
218
|
+
expect(res.statusCode).toBe(200);
|
|
219
|
+
expect(res.json()).toMatchObject({
|
|
220
|
+
origin: 'dev',
|
|
221
|
+
replay_of_event_id: source.id,
|
|
222
|
+
source: 'github',
|
|
223
|
+
event: 'push',
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('returns the replays list of dev events that replayed the event', async () => {
|
|
228
|
+
const source = await receivedEventFactory.create({workspaceId});
|
|
229
|
+
const runId = crypto.randomUUID();
|
|
230
|
+
const replay = await receivedEventFactory.create({
|
|
231
|
+
workspaceId,
|
|
232
|
+
origin: 'dev',
|
|
233
|
+
source: 'github',
|
|
234
|
+
event: 'push',
|
|
235
|
+
replayOfEventId: source.id,
|
|
236
|
+
outcome: 'routed',
|
|
237
|
+
matchedCount: 1,
|
|
238
|
+
receivedAt: new Date('2026-05-07T00:00:00.000Z'),
|
|
239
|
+
});
|
|
240
|
+
await decisionFactory.create({
|
|
241
|
+
receivedEventId: replay.id,
|
|
242
|
+
subscriptionKind: 'dev',
|
|
243
|
+
subscriptionId: null,
|
|
244
|
+
subscriptionName: 'on_push',
|
|
245
|
+
workflowDefinitionId: crypto.randomUUID(),
|
|
246
|
+
projectId: null,
|
|
247
|
+
decision: 'triggered',
|
|
248
|
+
runId,
|
|
249
|
+
runName: 'dev-replay',
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const res = await app.inject({method: 'GET', url: `/trigger-events/${source.id}`});
|
|
253
|
+
|
|
254
|
+
expect(res.statusCode).toBe(200);
|
|
255
|
+
expect(res.json().replays).toEqual([
|
|
256
|
+
{
|
|
257
|
+
id: replay.id,
|
|
258
|
+
received_at: '2026-05-07T00:00:00.000Z',
|
|
259
|
+
outcome: 'routed',
|
|
260
|
+
run_id: runId,
|
|
261
|
+
},
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('replays list carries null run ids and the outcome for refused replays', async () => {
|
|
266
|
+
const source = await receivedEventFactory.create({workspaceId});
|
|
267
|
+
const refused = await receivedEventFactory.create({
|
|
268
|
+
workspaceId,
|
|
269
|
+
origin: 'dev',
|
|
270
|
+
source: 'github',
|
|
271
|
+
event: 'push',
|
|
272
|
+
replayOfEventId: source.id,
|
|
273
|
+
outcome: 'discarded',
|
|
274
|
+
matchedCount: 0,
|
|
275
|
+
receivedAt: new Date('2026-05-07T01:00:00.000Z'),
|
|
276
|
+
});
|
|
277
|
+
await decisionFactory.create({
|
|
278
|
+
receivedEventId: refused.id,
|
|
279
|
+
subscriptionKind: 'dev',
|
|
280
|
+
subscriptionId: null,
|
|
281
|
+
subscriptionName: 'on_push',
|
|
282
|
+
workflowDefinitionId: crypto.randomUUID(),
|
|
283
|
+
projectId: null,
|
|
284
|
+
decision: 'filter-error',
|
|
285
|
+
runId: null,
|
|
286
|
+
runName: null,
|
|
287
|
+
reason: 'filter is false',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const res = await app.inject({method: 'GET', url: `/trigger-events/${source.id}`});
|
|
291
|
+
|
|
292
|
+
expect(res.statusCode).toBe(200);
|
|
293
|
+
expect(res.json().replays).toEqual([
|
|
294
|
+
{
|
|
295
|
+
id: refused.id,
|
|
296
|
+
received_at: '2026-05-07T01:00:00.000Z',
|
|
297
|
+
outcome: 'discarded',
|
|
298
|
+
run_id: null,
|
|
299
|
+
},
|
|
300
|
+
]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('does not surface replays from another workspace', async () => {
|
|
304
|
+
const source = await receivedEventFactory.create({workspaceId});
|
|
305
|
+
await receivedEventFactory.create({
|
|
306
|
+
workspaceId: crypto.randomUUID(),
|
|
307
|
+
origin: 'dev',
|
|
308
|
+
source: 'github',
|
|
309
|
+
event: 'push',
|
|
310
|
+
replayOfEventId: source.id,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const res = await app.inject({method: 'GET', url: `/trigger-events/${source.id}`});
|
|
314
|
+
|
|
315
|
+
expect(res.statusCode).toBe(200);
|
|
316
|
+
expect(res.json().replays).toEqual([]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('does not surface non-dev events with a replay link', async () => {
|
|
320
|
+
const source = await receivedEventFactory.create({workspaceId});
|
|
321
|
+
await receivedEventFactory.create({
|
|
322
|
+
workspaceId,
|
|
323
|
+
origin: 'integration',
|
|
324
|
+
replayOfEventId: source.id,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const res = await app.inject({method: 'GET', url: `/trigger-events/${source.id}`});
|
|
328
|
+
|
|
329
|
+
expect(res.statusCode).toBe(200);
|
|
330
|
+
expect(res.json().replays).toEqual([]);
|
|
331
|
+
});
|
|
332
|
+
|
|
154
333
|
test('returns an empty decisions list for a discarded event', async () => {
|
|
155
334
|
const event = await receivedEventFactory.create({
|
|
156
335
|
workspaceId,
|