@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.
Files changed (129) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +14 -10
  4. package/dist/core/entities/decision.d.ts +3 -2
  5. package/dist/core/entities/decision.d.ts.map +1 -1
  6. package/dist/core/entities/decision.js.map +1 -1
  7. package/dist/core/entities/job-listener-subscription.d.ts +2 -1
  8. package/dist/core/entities/job-listener-subscription.d.ts.map +1 -1
  9. package/dist/core/entities/job-listener-subscription.js.map +1 -1
  10. package/dist/core/entities/received-event.d.ts +14 -1
  11. package/dist/core/entities/received-event.d.ts.map +1 -1
  12. package/dist/core/entities/received-event.js +2 -1
  13. package/dist/core/entities/received-event.js.map +1 -1
  14. package/dist/core/entities/subscription.d.ts +2 -1
  15. package/dist/core/entities/subscription.d.ts.map +1 -1
  16. package/dist/core/entities/subscription.js.map +1 -1
  17. package/dist/core/fire-cron.d.ts.map +1 -1
  18. package/dist/core/fire-cron.js +3 -1
  19. package/dist/core/fire-cron.js.map +1 -1
  20. package/dist/core/fire-manual.d.ts.map +1 -1
  21. package/dist/core/fire-manual.js +3 -1
  22. package/dist/core/fire-manual.js.map +1 -1
  23. package/dist/core/record-trigger-history.d.ts +2 -0
  24. package/dist/core/record-trigger-history.d.ts.map +1 -1
  25. package/dist/core/record-trigger-history.js +13 -1
  26. package/dist/core/record-trigger-history.js.map +1 -1
  27. package/dist/core/route-event-to-job-listeners.js +2 -0
  28. package/dist/core/route-event-to-job-listeners.js.map +1 -1
  29. package/dist/db/db.d.ts +48 -14
  30. package/dist/db/db.d.ts.map +1 -1
  31. package/dist/db/event-history.d.ts +17 -0
  32. package/dist/db/event-history.d.ts.map +1 -1
  33. package/dist/db/event-history.js +45 -1
  34. package/dist/db/event-history.js.map +1 -1
  35. package/dist/db/event-queries.d.ts +5 -1
  36. package/dist/db/event-queries.d.ts.map +1 -1
  37. package/dist/db/event-queries.js +30 -4
  38. package/dist/db/event-queries.js.map +1 -1
  39. package/dist/db/index.d.ts +1 -1
  40. package/dist/db/index.d.ts.map +1 -1
  41. package/dist/db/index.js +1 -1
  42. package/dist/db/index.js.map +1 -1
  43. package/dist/db/job-listener-subscriptions.d.ts +1 -1
  44. package/dist/db/job-listener-subscriptions.d.ts.map +1 -1
  45. package/dist/db/job-listener-subscriptions.js +10 -3
  46. package/dist/db/job-listener-subscriptions.js.map +1 -1
  47. package/dist/db/schema/decisions.d.ts +3 -3
  48. package/dist/db/schema/decisions.d.ts.map +1 -1
  49. package/dist/db/schema/decisions.js +8 -3
  50. package/dist/db/schema/decisions.js.map +1 -1
  51. package/dist/db/schema/job-listener-subscriptions.d.ts +1 -1
  52. package/dist/db/schema/job-listener-subscriptions.d.ts.map +1 -1
  53. package/dist/db/schema/job-listener-subscriptions.js +2 -1
  54. package/dist/db/schema/job-listener-subscriptions.js.map +1 -1
  55. package/dist/db/schema/received-events.d.ts +38 -4
  56. package/dist/db/schema/received-events.d.ts.map +1 -1
  57. package/dist/db/schema/received-events.js +11 -2
  58. package/dist/db/schema/received-events.js.map +1 -1
  59. package/dist/db/schema/subscriptions.d.ts +1 -1
  60. package/dist/db/schema/subscriptions.d.ts.map +1 -1
  61. package/dist/db/schema/subscriptions.js +2 -1
  62. package/dist/db/schema/subscriptions.js.map +1 -1
  63. package/dist/db/subscription-event.d.ts +7 -0
  64. package/dist/db/subscription-event.d.ts.map +1 -0
  65. package/dist/db/subscription-event.js +19 -0
  66. package/dist/db/subscription-event.js.map +1 -0
  67. package/dist/db/subscriptions.d.ts +5 -1
  68. package/dist/db/subscriptions.d.ts.map +1 -1
  69. package/dist/db/subscriptions.js +10 -3
  70. package/dist/db/subscriptions.js.map +1 -1
  71. package/dist/presentation/dto/trigger-events.d.ts +3 -2
  72. package/dist/presentation/dto/trigger-events.d.ts.map +1 -1
  73. package/dist/presentation/dto/trigger-events.js +9 -0
  74. package/dist/presentation/dto/trigger-events.js.map +1 -1
  75. package/dist/presentation/routes/get-trigger-event.d.ts.map +1 -1
  76. package/dist/presentation/routes/get-trigger-event.js +8 -4
  77. package/dist/presentation/routes/get-trigger-event.js.map +1 -1
  78. package/dist/presentation/routes/list-trigger-event-facets.js +1 -1
  79. package/dist/presentation/routes/list-trigger-event-facets.js.map +1 -1
  80. package/dist/presentation/routes/list-trigger-events.d.ts.map +1 -1
  81. package/dist/presentation/routes/list-trigger-events.js +3 -1
  82. package/dist/presentation/routes/list-trigger-events.js.map +1 -1
  83. package/dist/tsconfig.test.tsbuildinfo +1 -1
  84. package/drizzle/0001_nullable_subscription_events.sql +2 -0
  85. package/drizzle/0002_dev_trigger_journal.sql +7 -0
  86. package/drizzle/0003_add_origin_facet_index.sql +1 -0
  87. package/drizzle/meta/0001_snapshot.json +905 -0
  88. package/drizzle/meta/0002_snapshot.json +947 -0
  89. package/drizzle/meta/0003_snapshot.json +968 -0
  90. package/drizzle/meta/_journal.json +21 -0
  91. package/package.json +7 -7
  92. package/src/core/dispatch-integration-event.test.ts +88 -0
  93. package/src/core/entities/decision.ts +3 -2
  94. package/src/core/entities/job-listener-subscription.ts +2 -1
  95. package/src/core/entities/received-event.ts +15 -1
  96. package/src/core/entities/subscription.ts +2 -1
  97. package/src/core/fire-cron.test.ts +20 -0
  98. package/src/core/fire-cron.ts +3 -1
  99. package/src/core/fire-manual.test.ts +25 -0
  100. package/src/core/fire-manual.ts +3 -1
  101. package/src/core/record-trigger-history.test.ts +78 -0
  102. package/src/core/record-trigger-history.ts +19 -0
  103. package/src/core/route-event-to-job-listeners.test.ts +47 -0
  104. package/src/core/route-event-to-job-listeners.ts +2 -0
  105. package/src/db/event-history.test.ts +123 -0
  106. package/src/db/event-history.ts +84 -1
  107. package/src/db/event-queries.ts +56 -3
  108. package/src/db/index.ts +1 -0
  109. package/src/db/job-listener-subscriptions.test.ts +133 -1
  110. package/src/db/job-listener-subscriptions.ts +5 -4
  111. package/src/db/schema/decisions.test.ts +93 -4
  112. package/src/db/schema/decisions.ts +12 -3
  113. package/src/db/schema/job-listener-subscriptions.ts +2 -1
  114. package/src/db/schema/received-events.test.ts +45 -0
  115. package/src/db/schema/received-events.ts +12 -1
  116. package/src/db/schema/subscriptions.ts +2 -1
  117. package/src/db/subscription-event.ts +24 -0
  118. package/src/db/subscriptions.test.ts +137 -0
  119. package/src/db/subscriptions.ts +9 -4
  120. package/src/presentation/dto/trigger-events.test.ts +193 -0
  121. package/src/presentation/dto/trigger-events.ts +12 -0
  122. package/src/presentation/routes/get-trigger-event.test.ts +179 -0
  123. package/src/presentation/routes/get-trigger-event.ts +15 -3
  124. package/src/presentation/routes/list-trigger-event-facets.test.ts +39 -2
  125. package/src/presentation/routes/list-trigger-event-facets.ts +1 -1
  126. package/src/presentation/routes/list-trigger-events.test.ts +99 -0
  127. package/src/presentation/routes/list-trigger-events.ts +4 -0
  128. package/test/factories/received-event.ts +2 -0
  129. 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
- event: text('event').notNull(),
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,
@@ -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, TriggerDto>;
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
- eq(triggerSubscriptions.event, params.event),
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,