@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
@@ -275,5 +275,88 @@ function listenerDecisionIdentity(params: {
275
275
  }
276
276
 
277
277
  function listenerSubscriptionName(subscription: JobListenerSubscription): string {
278
- return `listener ${subscription.kind}[${subscription.matcherOrdinal}] ${subscription.source}/${subscription.event}`;
278
+ // A NULL event is a source subscription; `*` keeps the audit name unambiguous.
279
+ return `listener ${subscription.kind}[${subscription.matcherOrdinal}] ${subscription.source}/${subscription.event ?? '*'}`;
280
+ }
281
+
282
+ export interface UpsertDevTriggeredDecisionParams {
283
+ receivedEventId: string;
284
+ triggerKey: string;
285
+ workflowDefinitionId: string;
286
+ run: {id: string; name: string};
287
+ }
288
+
289
+ // A dev journal entry has exactly one decision. Its partial unique index uses
290
+ // received_event_id because NULL subscription ids do not conflict in the
291
+ // regular subscription identity index.
292
+ export async function upsertDevTriggeredDecision(
293
+ params: UpsertDevTriggeredDecisionParams,
294
+ ): Promise<void> {
295
+ await upsertDevDecision(params, {
296
+ decision: 'triggered',
297
+ runId: params.run.id,
298
+ runName: params.run.name,
299
+ reason: null,
300
+ });
301
+ }
302
+
303
+ export interface UpsertDevFilterErrorDecisionParams {
304
+ receivedEventId: string;
305
+ triggerKey: string;
306
+ workflowDefinitionId: string;
307
+ reason: string;
308
+ }
309
+
310
+ // Refusals before run creation (filter false or evaluation error on replay).
311
+ export async function upsertDevFilterErrorDecision(
312
+ params: UpsertDevFilterErrorDecisionParams,
313
+ ): Promise<void> {
314
+ await upsertDevDecision(
315
+ params,
316
+ {
317
+ decision: 'filter-error',
318
+ runId: null,
319
+ runName: null,
320
+ reason: params.reason,
321
+ },
322
+ {preserveTriggered: true},
323
+ );
324
+ }
325
+
326
+ type DevDecisionValues = {
327
+ decision: 'triggered' | 'filter-error';
328
+ runId: string | null;
329
+ runName: string | null;
330
+ reason: string | null;
331
+ };
332
+
333
+ async function upsertDevDecision(
334
+ params: {
335
+ receivedEventId: string;
336
+ triggerKey: string;
337
+ workflowDefinitionId: string;
338
+ },
339
+ values: DevDecisionValues,
340
+ options: {preserveTriggered?: boolean} = {},
341
+ ): Promise<void> {
342
+ await db()
343
+ .insert(triggersDecisions)
344
+ .values({
345
+ receivedEventId: params.receivedEventId,
346
+ subscriptionKind: 'dev',
347
+ subscriptionId: null,
348
+ subscriptionName: params.triggerKey,
349
+ workflowDefinitionId: params.workflowDefinitionId,
350
+ projectId: null,
351
+ decision: values.decision,
352
+ runId: values.runId,
353
+ runName: values.runName,
354
+ reason: values.reason,
355
+ })
356
+ .onConflictDoUpdate({
357
+ target: triggersDecisions.receivedEventId,
358
+ targetWhere: sql`"subscription_kind" = 'dev'`,
359
+ set: values,
360
+ ...(options.preserveTriggered ? {setWhere: ne(triggersDecisions.decision, 'triggered')} : {}),
361
+ });
279
362
  }
@@ -1,9 +1,11 @@
1
1
  import {paginateTimestampIdRows, timestampIdCursorWhere} from '@shipfox/node-drizzle';
2
- import {and, asc, count, desc, eq, gte, inArray, lte, type SQL} from 'drizzle-orm';
2
+ import {and, asc, count, desc, eq, gte, inArray, isNotNull, lte, type SQL} from 'drizzle-orm';
3
3
  import type {PgColumn} from 'drizzle-orm/pg-core';
4
4
  import type {TriggerDecision} from '#core/entities/decision.js';
5
5
  import type {
6
+ TriggerEventOrigin,
6
7
  TriggerEventOutcome,
8
+ TriggerEventReplay,
7
9
  TriggerReceivedEvent,
8
10
  TriggerReceivedEventSummary,
9
11
  } from '#core/entities/received-event.js';
@@ -24,7 +26,9 @@ export interface TriggerEventCursor {
24
26
  export interface TriggerEventListFilters {
25
27
  source?: string[] | undefined;
26
28
  event?: string[] | undefined;
29
+ origins?: TriggerEventOrigin[] | undefined;
27
30
  outcomes?: TriggerEventOutcome[] | undefined;
31
+ replayable?: boolean | undefined;
28
32
  from?: Date | undefined;
29
33
  to?: Date | undefined;
30
34
  }
@@ -53,8 +57,16 @@ function listConditions(params: ListTriggerEventsParams): SQL[] {
53
57
  if (filters?.source?.length)
54
58
  conditions.push(inArray(triggersReceivedEvents.source, filters.source));
55
59
  if (filters?.event?.length) conditions.push(inArray(triggersReceivedEvents.event, filters.event));
60
+ if (filters?.origins?.length)
61
+ conditions.push(inArray(triggersReceivedEvents.origin, filters.origins));
56
62
  if (filters?.outcomes?.length)
57
63
  conditions.push(inArray(triggersReceivedEvents.outcome, filters.outcomes));
64
+ // `replayable` is the picker's convenience filter: integration events with a
65
+ // stored payload are the only ones a dev run can replay.
66
+ if (filters?.replayable) {
67
+ conditions.push(eq(triggersReceivedEvents.origin, 'integration'));
68
+ conditions.push(isNotNull(triggersReceivedEvents.payload));
69
+ }
58
70
  if (filters?.from) conditions.push(gte(triggersReceivedEvents.receivedAt, filters.from));
59
71
  if (filters?.to) conditions.push(lte(triggersReceivedEvents.receivedAt, filters.to));
60
72
  return conditions;
@@ -97,6 +109,7 @@ export interface TriggerEventFacet {
97
109
  export interface ListTriggerEventFacetsResult {
98
110
  sources: TriggerEventFacet[];
99
111
  events: TriggerEventFacet[];
112
+ origins: TriggerEventFacet[];
100
113
  }
101
114
 
102
115
  // Distinct filter values for a workspace, capped so one noisy integration can't flood
@@ -119,11 +132,51 @@ async function listFacet(workspaceId: string, column: PgColumn): Promise<Trigger
119
132
  export async function listTriggerEventFacets(params: {
120
133
  workspaceId: string;
121
134
  }): Promise<ListTriggerEventFacetsResult> {
122
- const [sources, events] = await Promise.all([
135
+ const [sources, events, origins] = await Promise.all([
123
136
  listFacet(params.workspaceId, triggersReceivedEvents.source),
124
137
  listFacet(params.workspaceId, triggersReceivedEvents.event),
138
+ listFacet(params.workspaceId, triggersReceivedEvents.origin),
125
139
  ]);
126
- return {sources, events};
140
+ return {sources, events, origins};
141
+ }
142
+
143
+ // The workspace predicate mirrors the route's workspace gate: replay links are
144
+ // deliberately FK-less, so the back-direction lookup must not surface a replay
145
+ // row that another workspace's writer pointed at this event id.
146
+ export async function listReplaysOfTriggerEvent(
147
+ eventId: string,
148
+ workspaceId: string,
149
+ ): Promise<TriggerEventReplay[]> {
150
+ const rows = await db()
151
+ .select({
152
+ id: triggersReceivedEvents.id,
153
+ receivedAt: triggersReceivedEvents.receivedAt,
154
+ outcome: triggersReceivedEvents.outcome,
155
+ runId: triggersDecisions.runId,
156
+ })
157
+ .from(triggersReceivedEvents)
158
+ .leftJoin(
159
+ triggersDecisions,
160
+ and(
161
+ eq(triggersDecisions.receivedEventId, triggersReceivedEvents.id),
162
+ eq(triggersDecisions.subscriptionKind, 'dev'),
163
+ ),
164
+ )
165
+ .where(
166
+ and(
167
+ eq(triggersReceivedEvents.replayOfEventId, eventId),
168
+ eq(triggersReceivedEvents.workspaceId, workspaceId),
169
+ eq(triggersReceivedEvents.origin, 'dev'),
170
+ ),
171
+ )
172
+ .orderBy(desc(triggersReceivedEvents.receivedAt), desc(triggersReceivedEvents.id));
173
+
174
+ return rows.map((row) => ({
175
+ id: row.id,
176
+ receivedAt: row.receivedAt,
177
+ outcome: row.outcome,
178
+ runId: row.runId,
179
+ }));
127
180
  }
128
181
 
129
182
  // `received_event_id` is a globally-unique uuid, so the parent-event lookup is the
package/src/db/index.ts CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  type ListTriggerEventsParams,
17
17
  type ListTriggerEventsResult,
18
18
  listDecisionsByReceivedEventId,
19
+ listReplaysOfTriggerEvent,
19
20
  listTriggerEventFacets,
20
21
  listTriggerEvents,
21
22
  type TriggerEventCursor,
@@ -5,7 +5,10 @@ import {jobListenerSubscriptions} from '#db/schema/job-listener-subscriptions.js
5
5
  import {onJobActivated} from '#presentation/subscribers/on-job-activated.js';
6
6
  import {onJobTerminated} from '#presentation/subscribers/on-job-terminated.js';
7
7
  import {jobListenerSubscriptionFactory} from '#test/index.js';
8
- import {findMatchingJobListenerSubscriptions} from './job-listener-subscriptions.js';
8
+ import {
9
+ findMatchingJobListenerSubscriptions,
10
+ projectJobListenerSubscriptions,
11
+ } from './job-listener-subscriptions.js';
9
12
 
10
13
  function buildActivatedPayload(
11
14
  overrides: Partial<WorkflowsJobActivatedEventDto> = {},
@@ -53,6 +56,60 @@ describe('job listener subscriptions', () => {
53
56
  });
54
57
  });
55
58
 
59
+ it('stores NULL for activated matchers with no event', async () => {
60
+ const jobId = crypto.randomUUID();
61
+ const workflowRunId = crypto.randomUUID();
62
+ const workspaceId = crypto.randomUUID();
63
+
64
+ await projectJobListenerSubscriptions({
65
+ workspaceId,
66
+ workflowRunId,
67
+ jobId,
68
+ on: [{source: 'github'}],
69
+ until: [{source: 'github'}],
70
+ });
71
+
72
+ const rows = await listJobSubscriptions(jobId);
73
+ expect(rows).toHaveLength(2);
74
+ expect(rows.map((row) => row.event)).toEqual([null, null]);
75
+ });
76
+
77
+ it.each(['', ' '])('rejects a blank matcher event %j', async (event) => {
78
+ const jobId = crypto.randomUUID();
79
+
80
+ await expect(
81
+ projectJobListenerSubscriptions({
82
+ workspaceId: crypto.randomUUID(),
83
+ workflowRunId: crypto.randomUUID(),
84
+ jobId,
85
+ on: [{source: 'github', event}],
86
+ until: null,
87
+ }),
88
+ ).rejects.toThrow('A github subscription event cannot be blank');
89
+ });
90
+
91
+ it('clears an exact matcher event to NULL when the matcher drops its event', async () => {
92
+ const jobId = crypto.randomUUID();
93
+ const workflowRunId = crypto.randomUUID();
94
+ const workspaceId = crypto.randomUUID();
95
+ const base = {workspaceId, workflowRunId, jobId};
96
+
97
+ await projectJobListenerSubscriptions({
98
+ ...base,
99
+ on: [{source: 'github', event: 'push'}],
100
+ until: null,
101
+ });
102
+ await projectJobListenerSubscriptions({
103
+ ...base,
104
+ on: [{source: 'github'}],
105
+ until: null,
106
+ });
107
+
108
+ const rows = await listJobSubscriptions(jobId);
109
+ expect(rows).toHaveLength(1);
110
+ expect(rows[0]?.event).toBeNull();
111
+ });
112
+
56
113
  it('does not project subscriptions for one-shot jobs', async () => {
57
114
  const payload = buildActivatedPayload({mode: 'one_shot'});
58
115
 
@@ -254,6 +311,81 @@ describe('job listener subscriptions', () => {
254
311
  expect(rows.map((row) => row.id)).toEqual([matching.id]);
255
312
  });
256
313
 
314
+ it('matches any event from the source for NULL-event on and until rows', async () => {
315
+ const workspaceId = crypto.randomUUID();
316
+ const jobId = crypto.randomUUID();
317
+ await jobListenerSubscriptionFactory.create({
318
+ workspaceId,
319
+ jobId,
320
+ kind: 'on',
321
+ matcherOrdinal: 0,
322
+ source: 'github',
323
+ event: null,
324
+ });
325
+ await jobListenerSubscriptionFactory.create({
326
+ workspaceId,
327
+ jobId,
328
+ kind: 'until',
329
+ matcherOrdinal: 0,
330
+ source: 'github',
331
+ event: null,
332
+ });
333
+ await jobListenerSubscriptionFactory.create({
334
+ workspaceId,
335
+ jobId,
336
+ kind: 'on',
337
+ matcherOrdinal: 1,
338
+ source: 'sentry',
339
+ event: null,
340
+ });
341
+
342
+ const rows = await findMatchingJobListenerSubscriptions({
343
+ workspaceId,
344
+ source: 'github',
345
+ event: 'push',
346
+ });
347
+
348
+ expect(rows.map((row) => [row.kind, row.matcherOrdinal]).sort()).toEqual([
349
+ ['on', 0],
350
+ ['until', 0],
351
+ ]);
352
+ });
353
+
354
+ it('keeps exact-event rows matching only their own event alongside NULL rows', async () => {
355
+ const workspaceId = crypto.randomUUID();
356
+ const jobId = crypto.randomUUID();
357
+ await jobListenerSubscriptionFactory.create({
358
+ workspaceId,
359
+ jobId,
360
+ kind: 'on',
361
+ matcherOrdinal: 0,
362
+ source: 'github',
363
+ event: null,
364
+ });
365
+ await jobListenerSubscriptionFactory.create({
366
+ workspaceId,
367
+ jobId,
368
+ kind: 'on',
369
+ matcherOrdinal: 1,
370
+ source: 'github',
371
+ event: 'push',
372
+ });
373
+
374
+ const pushRows = await findMatchingJobListenerSubscriptions({
375
+ workspaceId,
376
+ source: 'github',
377
+ event: 'push',
378
+ });
379
+ const prRows = await findMatchingJobListenerSubscriptions({
380
+ workspaceId,
381
+ source: 'github',
382
+ event: 'pull_request',
383
+ });
384
+
385
+ expect(pushRows.map((row) => row.matcherOrdinal).sort()).toEqual([0, 1]);
386
+ expect(prRows.map((row) => row.matcherOrdinal)).toEqual([0]);
387
+ });
388
+
257
389
  it('returns no subscriptions for a negative match', async () => {
258
390
  const workspaceId = crypto.randomUUID();
259
391
  await jobListenerSubscriptionFactory.create({
@@ -8,12 +8,13 @@ import {
8
8
  jobListenerSubscriptions,
9
9
  toJobListenerSubscription,
10
10
  } from './schema/job-listener-subscriptions.js';
11
+ import {normalizeSubscriptionEvent, subscriptionEventCondition} from './subscription-event.js';
11
12
 
12
13
  type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
13
14
 
14
15
  export interface ListenerMatcher {
15
16
  source: string;
16
- event: string;
17
+ event?: string | undefined;
17
18
  inputs?: Readonly<Record<string, unknown>> | undefined;
18
19
  filter?: string | undefined;
19
20
  filter_snapshot?: Readonly<Record<string, unknown>> | undefined;
@@ -57,7 +58,7 @@ export async function projectJobListenerSubscriptions(
57
58
  kind,
58
59
  matcherOrdinal,
59
60
  source: matcher.source,
60
- event: matcher.event,
61
+ event: normalizeSubscriptionEvent({source: matcher.source, event: matcher.event}),
61
62
  config,
62
63
  })
63
64
  .onConflictDoUpdate({
@@ -70,7 +71,7 @@ export async function projectJobListenerSubscriptions(
70
71
  workspaceId: params.workspaceId,
71
72
  workflowRunId: params.workflowRunId,
72
73
  source: matcher.source,
73
- event: matcher.event,
74
+ event: normalizeSubscriptionEvent({source: matcher.source, event: matcher.event}),
74
75
  config,
75
76
  },
76
77
  });
@@ -130,7 +131,7 @@ export async function findMatchingJobListenerSubscriptions(
130
131
  and(
131
132
  eq(jobListenerSubscriptions.workspaceId, params.workspaceId),
132
133
  eq(jobListenerSubscriptions.source, params.source),
133
- eq(jobListenerSubscriptions.event, params.event),
134
+ subscriptionEventCondition(jobListenerSubscriptions.event, params.event),
134
135
  ),
135
136
  );
136
137
  return rows.map(toJobListenerSubscription);
@@ -201,6 +201,92 @@ describe('triggers_decisions schema', () => {
201
201
  await expect(duplicate).rejects.toThrow();
202
202
  });
203
203
 
204
+ test('stores a dev decision with a null subscription id', async () => {
205
+ const receivedEventId = await insertEvent();
206
+ const [inserted] = await db()
207
+ .insert(triggersDecisions)
208
+ .values({
209
+ receivedEventId,
210
+ subscriptionKind: 'dev',
211
+ subscriptionId: null,
212
+ subscriptionName: 'on_issue',
213
+ workflowDefinitionId: crypto.randomUUID(),
214
+ decision: 'triggered',
215
+ runId: crypto.randomUUID(),
216
+ runName: 'Dev run',
217
+ })
218
+ .returning({id: triggersDecisions.id});
219
+ if (!inserted) throw new Error('insert returned no rows');
220
+ const [row] = await db()
221
+ .select()
222
+ .from(triggersDecisions)
223
+ .where(eq(triggersDecisions.id, inserted.id));
224
+ if (!row) throw new Error('select returned no rows');
225
+
226
+ expect(row.subscriptionKind).toBe('dev');
227
+ expect(row.subscriptionId).toBeNull();
228
+ expect(row.subscriptionName).toBe('on_issue');
229
+ expect(row.runId).not.toBeNull();
230
+ expect(row.runName).toBe('Dev run');
231
+ expect(toTriggerDecision(row)).toMatchObject({
232
+ subscriptionKind: 'dev',
233
+ subscriptionId: null,
234
+ decision: 'triggered',
235
+ });
236
+ });
237
+
238
+ test('the dev decision index rejects a second decision for the same event', async () => {
239
+ const receivedEventId = await insertEvent();
240
+ await db().insert(triggersDecisions).values({
241
+ receivedEventId,
242
+ subscriptionKind: 'dev',
243
+ subscriptionId: null,
244
+ subscriptionName: 'on_issue',
245
+ workflowDefinitionId: crypto.randomUUID(),
246
+ decision: 'triggered',
247
+ runId: crypto.randomUUID(),
248
+ runName: 'Dev run',
249
+ });
250
+ await expect(
251
+ db().insert(triggersDecisions).values({
252
+ receivedEventId,
253
+ subscriptionKind: 'dev',
254
+ subscriptionId: null,
255
+ subscriptionName: 'on_issue',
256
+ workflowDefinitionId: crypto.randomUUID(),
257
+ decision: 'filter-error',
258
+ reason: 'filter is false',
259
+ }),
260
+ ).rejects.toThrow();
261
+ });
262
+
263
+ test('enforces subscription ids by decision kind', async () => {
264
+ const receivedEventId = await insertEvent();
265
+
266
+ await expect(
267
+ db().insert(triggersDecisions).values({
268
+ receivedEventId,
269
+ subscriptionKind: 'trigger',
270
+ subscriptionId: null,
271
+ subscriptionName: 'Deploy production',
272
+ workflowDefinitionId: crypto.randomUUID(),
273
+ projectId: crypto.randomUUID(),
274
+ decision: 'triggered',
275
+ }),
276
+ ).rejects.toThrow();
277
+
278
+ await expect(
279
+ db().insert(triggersDecisions).values({
280
+ receivedEventId,
281
+ subscriptionKind: 'dev',
282
+ subscriptionId: crypto.randomUUID(),
283
+ subscriptionName: 'on_issue',
284
+ workflowDefinitionId: crypto.randomUUID(),
285
+ decision: 'triggered',
286
+ }),
287
+ ).rejects.toThrow();
288
+ });
289
+
204
290
  test('allows trigger and listener decisions with the same subscription id', async () => {
205
291
  const receivedEventId = await insertEvent();
206
292
  const subscriptionId = crypto.randomUUID();
@@ -234,11 +320,14 @@ describe('triggers_decisions schema', () => {
234
320
  });
235
321
 
236
322
  test('declares a database check for valid subscription kinds', () => {
237
- const subscriptionKindCheck = getTableConfig(triggersDecisions).checks.find(
238
- (check) => check.name === 'triggers_decisions_subscription_kind_ck',
239
- );
323
+ const checkNames = getTableConfig(triggersDecisions).checks.map((check) => check.name);
240
324
 
241
- expect(subscriptionKindCheck).toBeDefined();
325
+ expect(checkNames).toEqual(
326
+ expect.arrayContaining([
327
+ 'triggers_decisions_subscription_kind_ck',
328
+ 'triggers_decisions_subscription_id_ck',
329
+ ]),
330
+ );
242
331
  });
243
332
 
244
333
  test('maps listener identity fields', async () => {
@@ -6,7 +6,7 @@ import {pgTable} from './common.js';
6
6
  import {jobListenerMatcherKindEnum} from './job-listener-subscriptions.js';
7
7
  import {triggersReceivedEvents} from './received-events.js';
8
8
 
9
- const triggerDecisionSubscriptionKinds = ['trigger', 'listener'] as const;
9
+ const triggerDecisionSubscriptionKinds = ['trigger', 'listener', 'dev'] as const;
10
10
 
11
11
  export const triggersDecisions = pgTable(
12
12
  'decisions',
@@ -16,7 +16,9 @@ export const triggersDecisions = pgTable(
16
16
  .notNull()
17
17
  .references(() => triggersReceivedEvents.id, {onDelete: 'cascade'}),
18
18
  subscriptionKind: text('subscription_kind', {enum: triggerDecisionSubscriptionKinds}).notNull(),
19
- subscriptionId: uuid('subscription_id').notNull(),
19
+ // Null for `dev` decisions: the partial dev index enforces one decision
20
+ // per received event.
21
+ subscriptionId: uuid('subscription_id'),
20
22
  subscriptionName: text('subscription_name').notNull(),
21
23
  workflowDefinitionId: uuid('workflow_definition_id'),
22
24
  projectId: uuid('project_id'),
@@ -36,10 +38,17 @@ export const triggersDecisions = pgTable(
36
38
  table.subscriptionKind,
37
39
  table.subscriptionId,
38
40
  ),
41
+ uniqueIndex('triggers_decisions_dev_event_unique')
42
+ .on(table.receivedEventId)
43
+ .where(sql`${table.subscriptionKind} = 'dev'`),
39
44
  index('triggers_decisions_run_idx').on(table.runId),
40
45
  check(
41
46
  'triggers_decisions_subscription_kind_ck',
42
- sql`${table.subscriptionKind} IN ('trigger', 'listener')`,
47
+ sql`${table.subscriptionKind} IN ('trigger', 'listener', 'dev')`,
48
+ ),
49
+ check(
50
+ 'triggers_decisions_subscription_id_ck',
51
+ sql`(${table.subscriptionKind} = 'dev' AND ${table.subscriptionId} IS NULL) OR (${table.subscriptionKind} IN ('trigger', 'listener') AND ${table.subscriptionId} IS NOT NULL)`,
43
52
  ),
44
53
  ],
45
54
  );
@@ -27,7 +27,8 @@ export const jobListenerSubscriptions = pgTable(
27
27
  kind: jobListenerMatcherKindEnum('kind').notNull(),
28
28
  matcherOrdinal: integer('matcher_ordinal').notNull(),
29
29
  source: text('source').notNull(),
30
- event: text('event').notNull(),
30
+ // NULL is a source subscription: matches every event the source delivers.
31
+ event: text('event'),
31
32
  config: jsonb('config').notNull().$type<Record<string, unknown>>(),
32
33
  createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
33
34
  },
@@ -17,6 +17,7 @@ describe('toTriggerReceivedEvent', () => {
17
17
  provider: 'github',
18
18
  source: 'github',
19
19
  event: 'push',
20
+ replayOfEventId: '019e98ab-b90f-7265-b13c-8b441c991380',
20
21
  deliveryId: 'delivery-1',
21
22
  connectionId: '019e98ab-b90f-7265-b13c-8b441c991382',
22
23
  connectionName: 'Acme Production',
@@ -38,6 +39,7 @@ describe('toTriggerReceivedEvent', () => {
38
39
  provider: 'github',
39
40
  source: 'github',
40
41
  event: 'push',
42
+ replayOfEventId: '019e98ab-b90f-7265-b13c-8b441c991380',
41
43
  deliveryId: 'delivery-1',
42
44
  connectionId: row.connectionId,
43
45
  connectionName: 'Acme Production',
@@ -59,6 +61,7 @@ describe('toTriggerReceivedEvent', () => {
59
61
  provider: null,
60
62
  source: 'manual',
61
63
  event: 'fire',
64
+ replayOfEventId: null,
62
65
  deliveryId: null,
63
66
  connectionId: null,
64
67
  connectionName: null,
@@ -77,6 +80,7 @@ describe('toTriggerReceivedEvent', () => {
77
80
  expect(result.connectionName).toBeNull();
78
81
  expect(result.payload).toBeNull();
79
82
  expect(result.processedAt).toBeNull();
83
+ expect(result.replayOfEventId).toBeNull();
80
84
  });
81
85
  });
82
86
 
@@ -132,4 +136,45 @@ describe('triggers_received_events schema', () => {
132
136
 
133
137
  await expect(duplicate).rejects.toThrow();
134
138
  });
139
+
140
+ test('stores a dev origin with a replay_of_event_id link', async () => {
141
+ const source = await db()
142
+ .insert(triggersReceivedEvents)
143
+ .values({
144
+ eventRef: crypto.randomUUID(),
145
+ origin: 'integration',
146
+ workspaceId: crypto.randomUUID(),
147
+ source: 'github',
148
+ event: 'push',
149
+ receivedAt: new Date(),
150
+ })
151
+ .returning({id: triggersReceivedEvents.id});
152
+ if (!source[0]) throw new Error('insert returned no rows');
153
+
154
+ const values: TriggerReceivedEventInsertDb = {
155
+ eventRef: crypto.randomUUID(),
156
+ origin: 'dev',
157
+ workspaceId: crypto.randomUUID(),
158
+ source: 'github',
159
+ event: 'push',
160
+ replayOfEventId: source[0].id,
161
+ payload: {ref: 'refs/heads/main'},
162
+ receivedAt: new Date(),
163
+ };
164
+ const [inserted] = await db()
165
+ .insert(triggersReceivedEvents)
166
+ .values(values)
167
+ .returning({id: triggersReceivedEvents.id});
168
+ if (!inserted) throw new Error('insert returned no rows');
169
+ const [row] = await db()
170
+ .select()
171
+ .from(triggersReceivedEvents)
172
+ .where(eq(triggersReceivedEvents.id, inserted.id));
173
+
174
+ expect(row?.origin).toBe('dev');
175
+ expect(row?.replayOfEventId).toBe(source[0].id);
176
+ expect(toTriggerReceivedEvent(row as TriggerReceivedEventDb).replayOfEventId).toBe(
177
+ source[0].id,
178
+ );
179
+ });
135
180
  });
@@ -1,4 +1,5 @@
1
1
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {sql} from 'drizzle-orm';
2
3
  import {index, integer, jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
4
  import {
4
5
  type TriggerReceivedEvent,
@@ -21,6 +22,9 @@ export const triggersReceivedEvents = pgTable(
21
22
  provider: text('provider'),
22
23
  source: text('source').notNull(),
23
24
  event: text('event').notNull(),
25
+ // Source event this entry replays (dev runs only); no foreign key: the
26
+ // source row may be pruned while its replay link must stay.
27
+ replayOfEventId: uuid('replay_of_event_id'),
24
28
  deliveryId: text('delivery_id'),
25
29
  connectionId: uuid('connection_id'),
26
30
  connectionName: text('connection_name'),
@@ -38,9 +42,14 @@ export const triggersReceivedEvents = pgTable(
38
42
  table.receivedAt.desc(),
39
43
  ),
40
44
  index('triggers_received_events_prune_idx').on(table.createdAt),
41
- // Back the workspace-scoped facet group-by (distinct source / event values).
45
+ // Back the workspace-scoped facet group-by (distinct source / event / origin values).
42
46
  index('triggers_received_events_workspace_source_idx').on(table.workspaceId, table.source),
43
47
  index('triggers_received_events_workspace_event_idx').on(table.workspaceId, table.event),
48
+ index('triggers_received_events_workspace_origin_idx').on(table.workspaceId, table.origin),
49
+ // Back the replay link direction: which events replayed a given source event.
50
+ index('triggers_received_events_replay_of_event_id_idx')
51
+ .on(table.replayOfEventId)
52
+ .where(sql`${table.replayOfEventId} IS NOT NULL`),
44
53
  ],
45
54
  );
46
55
 
@@ -56,6 +65,7 @@ export function toTriggerReceivedEvent(row: TriggerReceivedEventDb): TriggerRece
56
65
  provider: row.provider,
57
66
  source: row.source,
58
67
  event: row.event,
68
+ replayOfEventId: row.replayOfEventId,
59
69
  deliveryId: row.deliveryId,
60
70
  connectionId: row.connectionId,
61
71
  connectionName: row.connectionName,
@@ -76,6 +86,7 @@ export const triggerReceivedEventSummaryColumns = {
76
86
  provider: triggersReceivedEvents.provider,
77
87
  source: triggersReceivedEvents.source,
78
88
  event: triggersReceivedEvents.event,
89
+ replayOfEventId: triggersReceivedEvents.replayOfEventId,
79
90
  deliveryId: triggersReceivedEvents.deliveryId,
80
91
  connectionId: triggersReceivedEvents.connectionId,
81
92
  connectionName: triggersReceivedEvents.connectionName,