@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
package/src/db/event-history.ts
CHANGED
|
@@ -275,5 +275,88 @@ function listenerDecisionIdentity(params: {
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
function listenerSubscriptionName(subscription: JobListenerSubscription): string {
|
|
278
|
-
|
|
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
|
}
|
package/src/db/event-queries.ts
CHANGED
|
@@ -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
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
238
|
-
(check) => check.name === 'triggers_decisions_subscription_kind_ck',
|
|
239
|
-
);
|
|
323
|
+
const checkNames = getTableConfig(triggersDecisions).checks.map((check) => check.name);
|
|
240
324
|
|
|
241
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|