@shipfox/api-triggers 13.1.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 +27 -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
|
@@ -8,6 +8,27 @@
|
|
|
8
8
|
"when": 1783263628394,
|
|
9
9
|
"tag": "0000_initial",
|
|
10
10
|
"breakpoints": true
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"idx": 1,
|
|
14
|
+
"version": "7",
|
|
15
|
+
"when": 1787484039797,
|
|
16
|
+
"tag": "0001_nullable_subscription_events",
|
|
17
|
+
"breakpoints": true
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"idx": 2,
|
|
21
|
+
"version": "7",
|
|
22
|
+
"when": 1787490844783,
|
|
23
|
+
"tag": "0002_dev_trigger_journal",
|
|
24
|
+
"breakpoints": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"idx": 3,
|
|
28
|
+
"version": "7",
|
|
29
|
+
"when": 1787497786244,
|
|
30
|
+
"tag": "0003_add_origin_facet_index",
|
|
31
|
+
"breakpoints": true
|
|
11
32
|
}
|
|
12
33
|
]
|
|
13
34
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-triggers",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "14.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
"drizzle-orm": "^0.45.2",
|
|
24
24
|
"zod": "^4.4.3",
|
|
25
25
|
"@shipfox/api-auth-context": "12.2.0",
|
|
26
|
-
"@shipfox/api-definitions-dto": "
|
|
27
|
-
"@shipfox/api-integration-core-dto": "
|
|
28
|
-
"@shipfox/api-triggers-dto": "
|
|
29
|
-
"@shipfox/api-workflows-dto": "
|
|
26
|
+
"@shipfox/api-definitions-dto": "14.0.0",
|
|
27
|
+
"@shipfox/api-integration-core-dto": "14.0.0",
|
|
28
|
+
"@shipfox/api-triggers-dto": "14.0.0",
|
|
29
|
+
"@shipfox/api-workflows-dto": "14.0.0",
|
|
30
30
|
"@shipfox/config": "1.2.4",
|
|
31
|
-
"@shipfox/expression": "2.2.
|
|
31
|
+
"@shipfox/expression": "2.2.1",
|
|
32
32
|
"@shipfox/inter-module": "0.2.3",
|
|
33
33
|
"@shipfox/node-drizzle": "0.3.5",
|
|
34
34
|
"@shipfox/node-error-monitoring": "0.3.0",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@shipfox/node-outbox": "0.2.6",
|
|
39
39
|
"@shipfox/node-postgres": "0.5.0",
|
|
40
40
|
"@shipfox/node-temporal": "0.4.5",
|
|
41
|
-
"@shipfox/workflow-document": "3.0
|
|
41
|
+
"@shipfox/workflow-document": "3.1.0"
|
|
42
42
|
},
|
|
43
43
|
"imports": {
|
|
44
44
|
"#*": "./dist/*"
|
|
@@ -310,6 +310,94 @@ describe('dispatchIntegrationEvent', () => {
|
|
|
310
310
|
);
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
+
test('fires a NULL-event source subscription for any delivered event, with the delivered event name', async () => {
|
|
314
|
+
const workspaceId = crypto.randomUUID();
|
|
315
|
+
const eventRef = crypto.randomUUID();
|
|
316
|
+
const subscription = await triggerSubscriptionFactory.create({
|
|
317
|
+
workspaceId,
|
|
318
|
+
source: 'github',
|
|
319
|
+
event: null,
|
|
320
|
+
config: {},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await dispatch({workspaceId, eventRef, source: 'github', event: 'pull_request'});
|
|
324
|
+
|
|
325
|
+
expect(runWorkflow).toHaveBeenCalledTimes(1);
|
|
326
|
+
expect(runWorkflow).toHaveBeenCalledWith(
|
|
327
|
+
expect.objectContaining({
|
|
328
|
+
projectId: subscription.projectId,
|
|
329
|
+
definitionId: subscription.workflowDefinitionId,
|
|
330
|
+
triggerPayload: expect.objectContaining({source: 'github', event: 'pull_request'}),
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
const event = await receivedEvent(eventRef);
|
|
334
|
+
if (!event) throw new Error('received event not found');
|
|
335
|
+
expect(event.source).toBe('github');
|
|
336
|
+
expect(event.event).toBe('pull_request');
|
|
337
|
+
expect(event.outcome).toBe('routed');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('fans out mixed exact-event and NULL-event subscriptions by delivered event', async () => {
|
|
341
|
+
const workspaceId = crypto.randomUUID();
|
|
342
|
+
const exactSubscription = await triggerSubscriptionFactory.create({
|
|
343
|
+
workspaceId,
|
|
344
|
+
source: 'github',
|
|
345
|
+
event: 'push',
|
|
346
|
+
config: {},
|
|
347
|
+
});
|
|
348
|
+
const wildcardSubscription = await triggerSubscriptionFactory.create({
|
|
349
|
+
workspaceId,
|
|
350
|
+
source: 'github',
|
|
351
|
+
event: null,
|
|
352
|
+
config: {},
|
|
353
|
+
});
|
|
354
|
+
const pushEventRef = crypto.randomUUID();
|
|
355
|
+
|
|
356
|
+
await dispatch({workspaceId, event: 'push', eventRef: pushEventRef});
|
|
357
|
+
|
|
358
|
+
expect(runWorkflow).toHaveBeenCalledTimes(2);
|
|
359
|
+
expect(runWorkflow.mock.calls.map(([params]) => params.idempotencyKey)).toEqual(
|
|
360
|
+
expect.arrayContaining([
|
|
361
|
+
`${exactSubscription.id}:${pushEventRef}`,
|
|
362
|
+
`${wildcardSubscription.id}:${pushEventRef}`,
|
|
363
|
+
]),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
runWorkflow.mockClear();
|
|
367
|
+
const pullRequestEventRef = crypto.randomUUID();
|
|
368
|
+
await dispatch({workspaceId, event: 'pull_request', eventRef: pullRequestEventRef});
|
|
369
|
+
|
|
370
|
+
expect(runWorkflow).toHaveBeenCalledTimes(1);
|
|
371
|
+
expect(runWorkflow).toHaveBeenCalledWith(
|
|
372
|
+
expect.objectContaining({
|
|
373
|
+
idempotencyKey: `${wildcardSubscription.id}:${pullRequestEventRef}`,
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('does not fire a NULL-event subscription for a different source or workspace', async () => {
|
|
379
|
+
const workspaceId = crypto.randomUUID();
|
|
380
|
+
await triggerSubscriptionFactory.create({
|
|
381
|
+
workspaceId,
|
|
382
|
+
source: 'github',
|
|
383
|
+
event: null,
|
|
384
|
+
config: {},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await dispatch({
|
|
388
|
+
workspaceId,
|
|
389
|
+
source: 'sentry',
|
|
390
|
+
event: 'alert_triggered',
|
|
391
|
+
});
|
|
392
|
+
await dispatch({
|
|
393
|
+
workspaceId: crypto.randomUUID(),
|
|
394
|
+
source: 'github',
|
|
395
|
+
event: 'push',
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
399
|
+
});
|
|
400
|
+
|
|
313
401
|
test('passes triggerIdempotencyKey = subscription.id:eventRef to runWorkflow', async () => {
|
|
314
402
|
const workspaceId = crypto.randomUUID();
|
|
315
403
|
const subscription = await triggerSubscriptionFactory.create({
|
|
@@ -2,13 +2,14 @@ import type {JobListenerMatcherKind} from './job-listener-subscription.js';
|
|
|
2
2
|
|
|
3
3
|
export const triggerDecisionOutcomes = ['triggered', 'filter-error', 'dispatch-error'] as const;
|
|
4
4
|
export type TriggerDecisionOutcome = (typeof triggerDecisionOutcomes)[number];
|
|
5
|
-
export type TriggerDecisionSubscriptionKind = 'trigger' | 'listener';
|
|
5
|
+
export type TriggerDecisionSubscriptionKind = 'trigger' | 'listener' | 'dev';
|
|
6
6
|
|
|
7
7
|
export interface TriggerDecision {
|
|
8
8
|
id: string;
|
|
9
9
|
receivedEventId: string;
|
|
10
10
|
subscriptionKind: TriggerDecisionSubscriptionKind;
|
|
11
|
-
|
|
11
|
+
/** Null for `dev` decisions: a dev journal entry has no subscription row. */
|
|
12
|
+
subscriptionId: string | null;
|
|
12
13
|
subscriptionName: string;
|
|
13
14
|
workflowDefinitionId: string | null;
|
|
14
15
|
projectId: string | null;
|
|
@@ -8,7 +8,8 @@ export interface JobListenerSubscription {
|
|
|
8
8
|
kind: JobListenerMatcherKind;
|
|
9
9
|
matcherOrdinal: number;
|
|
10
10
|
source: string;
|
|
11
|
-
|
|
11
|
+
/** NULL is a source subscription: matches every event the source delivers. */
|
|
12
|
+
event: string | null;
|
|
12
13
|
config: Record<string, unknown>;
|
|
13
14
|
createdAt: Date;
|
|
14
15
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const triggerEventOrigins = ['integration', 'manual', 'cron'] as const;
|
|
1
|
+
export const triggerEventOrigins = ['integration', 'manual', 'cron', 'dev'] as const;
|
|
2
2
|
export type TriggerEventOrigin = (typeof triggerEventOrigins)[number];
|
|
3
3
|
|
|
4
4
|
export const triggerEventOutcomes = [
|
|
@@ -18,6 +18,8 @@ export interface TriggerReceivedEvent {
|
|
|
18
18
|
provider: string | null;
|
|
19
19
|
source: string;
|
|
20
20
|
event: string;
|
|
21
|
+
/** The source event this entry replays, when origin is `dev`. */
|
|
22
|
+
replayOfEventId: string | null;
|
|
21
23
|
deliveryId: string | null;
|
|
22
24
|
connectionId: string | null;
|
|
23
25
|
connectionName: string | null;
|
|
@@ -29,6 +31,18 @@ export interface TriggerReceivedEvent {
|
|
|
29
31
|
createdAt: Date;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A dev journal entry that replayed a source event. A missing run ID means
|
|
36
|
+
* that the replay has no recorded workflow run, including refusals and an
|
|
37
|
+
* incomplete or failed dev decision write.
|
|
38
|
+
*/
|
|
39
|
+
export interface TriggerEventReplay {
|
|
40
|
+
id: string;
|
|
41
|
+
receivedAt: Date;
|
|
42
|
+
outcome: TriggerEventOutcome;
|
|
43
|
+
runId: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
/**
|
|
33
47
|
* Trigger event shape for list read models.
|
|
34
48
|
* It omits payload because webhook bodies can be large/untrusted and only detail views render them.
|
|
@@ -5,7 +5,8 @@ export interface TriggerSubscription {
|
|
|
5
5
|
workflowDefinitionId: string;
|
|
6
6
|
name: string;
|
|
7
7
|
source: string;
|
|
8
|
-
|
|
8
|
+
/** NULL is a source subscription: matches every event the source delivers. */
|
|
9
|
+
event: string | null;
|
|
9
10
|
config: Record<string, unknown>;
|
|
10
11
|
createdAt: Date;
|
|
11
12
|
updatedAt: Date;
|
|
@@ -140,6 +140,26 @@ describe('fireCronSubscription', () => {
|
|
|
140
140
|
expect(decisions[0]?.decision).toBe('dispatch-error');
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
test('falls back to tick in history when a bad row stores a NULL event', async () => {
|
|
144
|
+
const subscription = await triggerSubscriptionFactory.create({
|
|
145
|
+
source: 'cron',
|
|
146
|
+
event: null,
|
|
147
|
+
config: {},
|
|
148
|
+
});
|
|
149
|
+
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'Cron run'});
|
|
150
|
+
|
|
151
|
+
await fireCronSubscription({
|
|
152
|
+
workflows,
|
|
153
|
+
subscriptionId: subscription.id,
|
|
154
|
+
scheduledSlot: SLOT,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const [event] = await eventsForWorkspace(subscription.workspaceId);
|
|
158
|
+
if (!event) throw new Error('received event not found');
|
|
159
|
+
expect(event.event).toBe('tick');
|
|
160
|
+
expect(event.outcome).toBe('routed');
|
|
161
|
+
});
|
|
162
|
+
|
|
143
163
|
test('throws and records nothing when the subscription is not a cron trigger', async () => {
|
|
144
164
|
const subscription = await triggerSubscriptionFactory.create({
|
|
145
165
|
source: 'manual',
|
package/src/core/fire-cron.ts
CHANGED
|
@@ -47,7 +47,9 @@ export async function fireCronSubscription(
|
|
|
47
47
|
workspaceId: subscription.workspaceId,
|
|
48
48
|
provider: null,
|
|
49
49
|
source: subscription.source,
|
|
50
|
-
event
|
|
50
|
+
// Cron has no inbound event. Keep the canonical history event for a row
|
|
51
|
+
// that bypassed the projection write-path validation.
|
|
52
|
+
event: subscription.event ?? 'tick',
|
|
51
53
|
deliveryId: null,
|
|
52
54
|
connectionId: null,
|
|
53
55
|
connectionName: null,
|
|
@@ -132,6 +132,31 @@ describe('fireManualSubscription (trigger history)', () => {
|
|
|
132
132
|
expect(decisions[0]?.reason).toContain('definition-not-found');
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
test('falls back to fire in history when a bad row stores a NULL event', async () => {
|
|
136
|
+
const subscription = await triggerSubscriptionFactory.create({
|
|
137
|
+
source: 'manual',
|
|
138
|
+
event: null,
|
|
139
|
+
config: {},
|
|
140
|
+
});
|
|
141
|
+
const run = {id: crypto.randomUUID(), name: 'Manual run'};
|
|
142
|
+
runWorkflow.mockResolvedValue(run);
|
|
143
|
+
|
|
144
|
+
await fireManualSubscription({
|
|
145
|
+
workflows,
|
|
146
|
+
subscriptionId: subscription.id,
|
|
147
|
+
callerWorkspaceId: subscription.workspaceId,
|
|
148
|
+
userId: crypto.randomUUID(),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const [event] = await db()
|
|
152
|
+
.select()
|
|
153
|
+
.from(triggersReceivedEvents)
|
|
154
|
+
.where(eq(triggersReceivedEvents.eventRef, run.id));
|
|
155
|
+
if (!event) throw new Error('received event not found');
|
|
156
|
+
expect(event.event).toBe('fire');
|
|
157
|
+
expect(event.outcome).toBe('routed');
|
|
158
|
+
});
|
|
159
|
+
|
|
135
160
|
test('does not record a received event when the subscription is not a manual trigger', async () => {
|
|
136
161
|
const subscription = await triggerSubscriptionFactory.create({
|
|
137
162
|
source: 'github',
|
package/src/core/fire-manual.ts
CHANGED
|
@@ -46,7 +46,9 @@ export async function fireManualSubscription(
|
|
|
46
46
|
workspaceId: subscription.workspaceId,
|
|
47
47
|
provider: null,
|
|
48
48
|
source: subscription.source,
|
|
49
|
-
event
|
|
49
|
+
// Manual triggers have no inbound event. Keep the canonical history event
|
|
50
|
+
// for a row that bypassed the projection write-path validation.
|
|
51
|
+
event: subscription.event ?? 'fire',
|
|
50
52
|
deliveryId: null,
|
|
51
53
|
connectionId: null,
|
|
52
54
|
connectionName: null,
|
|
@@ -4,6 +4,8 @@ const runWorkflow = vi.fn();
|
|
|
4
4
|
const insertReceivedEvent = vi.fn();
|
|
5
5
|
const markReceivedEventRouted = vi.fn();
|
|
6
6
|
const upsertTriggeredDecision = vi.fn();
|
|
7
|
+
const upsertDevTriggeredDecision = vi.fn();
|
|
8
|
+
const upsertDevFilterErrorDecision = vi.fn();
|
|
7
9
|
|
|
8
10
|
vi.mock('#db/event-history.js', () => ({
|
|
9
11
|
insertReceivedEvent: (...args: unknown[]) => insertReceivedEvent(...args),
|
|
@@ -12,6 +14,8 @@ vi.mock('#db/event-history.js', () => ({
|
|
|
12
14
|
markReceivedEventFailed: vi.fn(),
|
|
13
15
|
markReceivedEventErrored: vi.fn(),
|
|
14
16
|
upsertTriggeredDecision: (...args: unknown[]) => upsertTriggeredDecision(...args),
|
|
17
|
+
upsertDevTriggeredDecision: (...args: unknown[]) => upsertDevTriggeredDecision(...args),
|
|
18
|
+
upsertDevFilterErrorDecision: (...args: unknown[]) => upsertDevFilterErrorDecision(...args),
|
|
15
19
|
upsertDispatchErrorDecision: vi.fn(),
|
|
16
20
|
upsertFilterErrorDecision: vi.fn(),
|
|
17
21
|
upsertListenerTriggeredDecision: vi.fn(),
|
|
@@ -52,6 +56,12 @@ describe('trigger history is best-effort and never blocks triggering', () => {
|
|
|
52
56
|
await expect(
|
|
53
57
|
recorder.triggered(subscription, {id: crypto.randomUUID(), name: 'r'}),
|
|
54
58
|
).resolves.toBeUndefined();
|
|
59
|
+
await expect(
|
|
60
|
+
recorder.devTriggered('on_issue', crypto.randomUUID(), {id: crypto.randomUUID(), name: 'r'}),
|
|
61
|
+
).resolves.toBeUndefined();
|
|
62
|
+
await expect(
|
|
63
|
+
recorder.devFilterErrored('on_issue', crypto.randomUUID(), 'filter is false'),
|
|
64
|
+
).resolves.toBeUndefined();
|
|
55
65
|
await expect(recorder.dispatchErrored(subscription, 'boom')).resolves.toBeUndefined();
|
|
56
66
|
await expect(recorder.filterErrored(subscription, 'bad filter')).resolves.toBeUndefined();
|
|
57
67
|
await expect(recorder.listenerTriggered(listenerSubscription)).resolves.toBeUndefined();
|
|
@@ -126,6 +136,74 @@ describe('a per-write failure after a successful insert is swallowed', () => {
|
|
|
126
136
|
});
|
|
127
137
|
});
|
|
128
138
|
|
|
139
|
+
describe('dev recorder variants', () => {
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
insertReceivedEvent.mockReset();
|
|
142
|
+
upsertDevTriggeredDecision.mockReset();
|
|
143
|
+
upsertDevFilterErrorDecision.mockReset();
|
|
144
|
+
insertReceivedEvent.mockResolvedValue(crypto.randomUUID());
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('devTriggered writes a dev decision with the trigger key, lineage id, and run', async () => {
|
|
148
|
+
const recorder = await beginTriggerHistory({
|
|
149
|
+
eventRef: crypto.randomUUID(),
|
|
150
|
+
origin: 'dev',
|
|
151
|
+
workspaceId: crypto.randomUUID(),
|
|
152
|
+
provider: null,
|
|
153
|
+
source: 'manual',
|
|
154
|
+
event: 'fire',
|
|
155
|
+
deliveryId: null,
|
|
156
|
+
connectionId: null,
|
|
157
|
+
connectionName: null,
|
|
158
|
+
payload: null,
|
|
159
|
+
receivedAt: new Date(),
|
|
160
|
+
});
|
|
161
|
+
const run = {id: crypto.randomUUID(), name: 'Dev run'};
|
|
162
|
+
|
|
163
|
+
await recorder.devTriggered('on_issue', '019e98ab-0000-0000-0000-000000000001', run);
|
|
164
|
+
|
|
165
|
+
// The dev decision carries no subscription row: the trigger key stands in
|
|
166
|
+
// for subscription_name and the lineage id for workflow_definition_id.
|
|
167
|
+
expect(upsertDevTriggeredDecision).toHaveBeenCalledTimes(1);
|
|
168
|
+
expect(upsertDevTriggeredDecision).toHaveBeenCalledWith({
|
|
169
|
+
receivedEventId: expect.any(String),
|
|
170
|
+
triggerKey: 'on_issue',
|
|
171
|
+
workflowDefinitionId: '019e98ab-0000-0000-0000-000000000001',
|
|
172
|
+
run,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('devFilterErrored writes a dev filter-error decision with the reason', async () => {
|
|
177
|
+
const recorder = await beginTriggerHistory({
|
|
178
|
+
eventRef: crypto.randomUUID(),
|
|
179
|
+
origin: 'dev',
|
|
180
|
+
workspaceId: crypto.randomUUID(),
|
|
181
|
+
provider: null,
|
|
182
|
+
source: 'github',
|
|
183
|
+
event: 'push',
|
|
184
|
+
deliveryId: null,
|
|
185
|
+
connectionId: null,
|
|
186
|
+
connectionName: null,
|
|
187
|
+
payload: null,
|
|
188
|
+
receivedAt: new Date(),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await recorder.devFilterErrored(
|
|
192
|
+
'on_push',
|
|
193
|
+
'019e98ab-0000-0000-0000-000000000002',
|
|
194
|
+
'filter is false',
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
expect(upsertDevFilterErrorDecision).toHaveBeenCalledTimes(1);
|
|
198
|
+
expect(upsertDevFilterErrorDecision).toHaveBeenCalledWith({
|
|
199
|
+
receivedEventId: expect.any(String),
|
|
200
|
+
triggerKey: 'on_push',
|
|
201
|
+
workflowDefinitionId: '019e98ab-0000-0000-0000-000000000002',
|
|
202
|
+
reason: 'filter is false',
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
129
207
|
describe('toReason', () => {
|
|
130
208
|
test('caps an over-long message at the maximum reason length', () => {
|
|
131
209
|
const reason = toReason(new Error('x'.repeat(5000)));
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
markReceivedEventErrored,
|
|
10
10
|
markReceivedEventFailed,
|
|
11
11
|
markReceivedEventRouted,
|
|
12
|
+
upsertDevFilterErrorDecision,
|
|
13
|
+
upsertDevTriggeredDecision,
|
|
12
14
|
upsertDispatchErrorDecision,
|
|
13
15
|
upsertFilterErrorDecision,
|
|
14
16
|
upsertListenerDispatchErrorDecision,
|
|
@@ -38,6 +40,10 @@ export interface TriggerRun {
|
|
|
38
40
|
|
|
39
41
|
export interface TriggerHistoryRecorder {
|
|
40
42
|
triggered(subscription: TriggerSubscription, run: TriggerRun): Promise<void>;
|
|
43
|
+
// Dev journal entries carry no subscription row: subscription_name is the
|
|
44
|
+
// trigger key and workflow_definition_id the workflow lineage id.
|
|
45
|
+
devTriggered(triggerKey: string, workflowDefinitionId: string, run: TriggerRun): Promise<void>;
|
|
46
|
+
devFilterErrored(triggerKey: string, workflowDefinitionId: string, reason: string): Promise<void>;
|
|
41
47
|
filterErrored(subscription: TriggerSubscription, reason: string): Promise<void>;
|
|
42
48
|
dispatchErrored(subscription: TriggerSubscription, reason: string): Promise<void>;
|
|
43
49
|
listenerTriggered(subscription: JobListenerSubscription): Promise<void>;
|
|
@@ -88,6 +94,19 @@ export async function beginTriggerHistory(
|
|
|
88
94
|
(id) => upsertTriggeredDecision({receivedEventId: id, subscription, run}),
|
|
89
95
|
subscription.id,
|
|
90
96
|
),
|
|
97
|
+
devTriggered: (triggerKey, workflowDefinitionId, run) =>
|
|
98
|
+
record('dev-triggered-decision', (id) =>
|
|
99
|
+
upsertDevTriggeredDecision({receivedEventId: id, triggerKey, workflowDefinitionId, run}),
|
|
100
|
+
),
|
|
101
|
+
devFilterErrored: (triggerKey, workflowDefinitionId, reason) =>
|
|
102
|
+
record('dev-filter-error-decision', (id) =>
|
|
103
|
+
upsertDevFilterErrorDecision({
|
|
104
|
+
receivedEventId: id,
|
|
105
|
+
triggerKey,
|
|
106
|
+
workflowDefinitionId,
|
|
107
|
+
reason,
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
91
110
|
filterErrored: (subscription, reason) =>
|
|
92
111
|
record(
|
|
93
112
|
'filter-error-decision',
|
|
@@ -66,6 +66,8 @@ function route(overrides: RouteOverrides = {}) {
|
|
|
66
66
|
function buildHistory(): TriggerHistoryRecorder {
|
|
67
67
|
return {
|
|
68
68
|
triggered: vi.fn(),
|
|
69
|
+
devTriggered: vi.fn(),
|
|
70
|
+
devFilterErrored: vi.fn(),
|
|
69
71
|
filterErrored: vi.fn(),
|
|
70
72
|
dispatchErrored: vi.fn(),
|
|
71
73
|
listenerTriggered,
|
|
@@ -194,6 +196,51 @@ describe('routeEventToJobListeners', () => {
|
|
|
194
196
|
);
|
|
195
197
|
});
|
|
196
198
|
|
|
199
|
+
it('gives a NULL-event until matcher precedence over an exact on matcher', async () => {
|
|
200
|
+
const workspaceId = crypto.randomUUID();
|
|
201
|
+
const jobId = crypto.randomUUID();
|
|
202
|
+
await jobListenerSubscriptionFactory.create({
|
|
203
|
+
workspaceId,
|
|
204
|
+
jobId,
|
|
205
|
+
kind: 'on',
|
|
206
|
+
matcherOrdinal: 0,
|
|
207
|
+
source: 'github',
|
|
208
|
+
event: 'pull_request',
|
|
209
|
+
});
|
|
210
|
+
await jobListenerSubscriptionFactory.create({
|
|
211
|
+
workspaceId,
|
|
212
|
+
jobId,
|
|
213
|
+
kind: 'until',
|
|
214
|
+
matcherOrdinal: 0,
|
|
215
|
+
source: 'github',
|
|
216
|
+
event: null,
|
|
217
|
+
});
|
|
218
|
+
await jobListenerSubscriptionFactory.create({
|
|
219
|
+
workspaceId,
|
|
220
|
+
jobId: crypto.randomUUID(),
|
|
221
|
+
kind: 'on',
|
|
222
|
+
matcherOrdinal: 0,
|
|
223
|
+
source: 'sentry',
|
|
224
|
+
event: null,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const result = await route({workspaceId, event: 'pull_request'});
|
|
228
|
+
|
|
229
|
+
expect(deliverEventToListener).toHaveBeenCalledTimes(1);
|
|
230
|
+
expect(deliverEventToListener).toHaveBeenCalledWith(
|
|
231
|
+
expect.objectContaining({
|
|
232
|
+
jobId,
|
|
233
|
+
source: 'github',
|
|
234
|
+
event: 'pull_request',
|
|
235
|
+
disposition: 'resolve',
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
expect(listenerTriggered).toHaveBeenCalledWith(
|
|
239
|
+
expect.objectContaining({jobId, kind: 'until', source: 'github', event: null}),
|
|
240
|
+
);
|
|
241
|
+
expect(result).toMatchObject({engagedCount: 1, matchedJobCount: 1, acceptedJobCount: 1});
|
|
242
|
+
});
|
|
243
|
+
|
|
197
244
|
it('does not deliver when no listener subscription matches', async () => {
|
|
198
245
|
const workspaceId = crypto.randomUUID();
|
|
199
246
|
await jobListenerSubscriptionFactory.create({
|
|
@@ -335,6 +335,8 @@ function listenerDisposition(subscription: JobListenerSubscription): 'fire' | 'r
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
function listenerMatcherSortKey(subscription: JobListenerSubscription): string {
|
|
338
|
+
// An until matcher wins over an on matcher, including when the until matcher
|
|
339
|
+
// is a source wildcard. Resolving the job intentionally suppresses delivery.
|
|
338
340
|
const kindRank = subscription.kind === 'until' ? '0' : '1';
|
|
339
341
|
return `${kindRank}:${subscription.matcherOrdinal.toString().padStart(10, '0')}`;
|
|
340
342
|
}
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
markReceivedEventErrored,
|
|
10
10
|
markReceivedEventFailed,
|
|
11
11
|
markReceivedEventRouted,
|
|
12
|
+
upsertDevFilterErrorDecision,
|
|
13
|
+
upsertDevTriggeredDecision,
|
|
12
14
|
upsertDispatchErrorDecision,
|
|
13
15
|
upsertFilterErrorDecision,
|
|
14
16
|
upsertListenerDispatchErrorDecision,
|
|
@@ -257,6 +259,117 @@ describe('received-event outcome transitions', () => {
|
|
|
257
259
|
});
|
|
258
260
|
});
|
|
259
261
|
|
|
262
|
+
describe('dev decision inserts', () => {
|
|
263
|
+
it('records a dev triggered decision with a null subscription id', async () => {
|
|
264
|
+
const receivedEventId = await insertReceivedEvent(buildEventParams());
|
|
265
|
+
const workflowDefinitionId = crypto.randomUUID();
|
|
266
|
+
const run = {id: crypto.randomUUID(), name: 'Dev run'};
|
|
267
|
+
|
|
268
|
+
await upsertDevTriggeredDecision({
|
|
269
|
+
receivedEventId,
|
|
270
|
+
triggerKey: 'on_issue',
|
|
271
|
+
workflowDefinitionId,
|
|
272
|
+
run,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const rows = await decisionsFor(receivedEventId);
|
|
276
|
+
expect(rows).toHaveLength(1);
|
|
277
|
+
expect(rows[0]).toMatchObject({
|
|
278
|
+
subscriptionKind: 'dev',
|
|
279
|
+
subscriptionId: null,
|
|
280
|
+
subscriptionName: 'on_issue',
|
|
281
|
+
workflowDefinitionId,
|
|
282
|
+
projectId: null,
|
|
283
|
+
decision: 'triggered',
|
|
284
|
+
runId: run.id,
|
|
285
|
+
runName: run.name,
|
|
286
|
+
reason: null,
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('records a dev filter-error decision with the reason and no run', async () => {
|
|
291
|
+
const receivedEventId = await insertReceivedEvent(buildEventParams());
|
|
292
|
+
const workflowDefinitionId = crypto.randomUUID();
|
|
293
|
+
|
|
294
|
+
await upsertDevFilterErrorDecision({
|
|
295
|
+
receivedEventId,
|
|
296
|
+
triggerKey: 'on_push',
|
|
297
|
+
workflowDefinitionId,
|
|
298
|
+
reason: 'filter is false',
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const rows = await decisionsFor(receivedEventId);
|
|
302
|
+
expect(rows).toHaveLength(1);
|
|
303
|
+
expect(rows[0]).toMatchObject({
|
|
304
|
+
subscriptionKind: 'dev',
|
|
305
|
+
subscriptionId: null,
|
|
306
|
+
subscriptionName: 'on_push',
|
|
307
|
+
workflowDefinitionId,
|
|
308
|
+
projectId: null,
|
|
309
|
+
decision: 'filter-error',
|
|
310
|
+
runId: null,
|
|
311
|
+
runName: null,
|
|
312
|
+
reason: 'filter is false',
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('converges repeated dev triggered writes to one decision', async () => {
|
|
317
|
+
const receivedEventId = await insertReceivedEvent(buildEventParams());
|
|
318
|
+
const workflowDefinitionId = crypto.randomUUID();
|
|
319
|
+
const firstRun = {id: crypto.randomUUID(), name: 'First dev run'};
|
|
320
|
+
const secondRun = {id: crypto.randomUUID(), name: 'Second dev run'};
|
|
321
|
+
|
|
322
|
+
await upsertDevTriggeredDecision({
|
|
323
|
+
receivedEventId,
|
|
324
|
+
triggerKey: 'on_issue',
|
|
325
|
+
workflowDefinitionId,
|
|
326
|
+
run: firstRun,
|
|
327
|
+
});
|
|
328
|
+
await upsertDevTriggeredDecision({
|
|
329
|
+
receivedEventId,
|
|
330
|
+
triggerKey: 'on_issue',
|
|
331
|
+
workflowDefinitionId,
|
|
332
|
+
run: secondRun,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const rows = await decisionsFor(receivedEventId);
|
|
336
|
+
expect(rows).toHaveLength(1);
|
|
337
|
+
expect(rows[0]).toMatchObject({
|
|
338
|
+
decision: 'triggered',
|
|
339
|
+
runId: secondRun.id,
|
|
340
|
+
runName: secondRun.name,
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('does not downgrade a dev triggered decision on a filter-error replay', async () => {
|
|
345
|
+
const receivedEventId = await insertReceivedEvent(buildEventParams());
|
|
346
|
+
const workflowDefinitionId = crypto.randomUUID();
|
|
347
|
+
const run = {id: crypto.randomUUID(), name: 'Dev run'};
|
|
348
|
+
|
|
349
|
+
await upsertDevTriggeredDecision({
|
|
350
|
+
receivedEventId,
|
|
351
|
+
triggerKey: 'on_issue',
|
|
352
|
+
workflowDefinitionId,
|
|
353
|
+
run,
|
|
354
|
+
});
|
|
355
|
+
await upsertDevFilterErrorDecision({
|
|
356
|
+
receivedEventId,
|
|
357
|
+
triggerKey: 'on_issue',
|
|
358
|
+
workflowDefinitionId,
|
|
359
|
+
reason: 'late filter failure',
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const rows = await decisionsFor(receivedEventId);
|
|
363
|
+
expect(rows).toHaveLength(1);
|
|
364
|
+
expect(rows[0]).toMatchObject({
|
|
365
|
+
decision: 'triggered',
|
|
366
|
+
runId: run.id,
|
|
367
|
+
runName: run.name,
|
|
368
|
+
reason: null,
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
260
373
|
describe('decision upserts', () => {
|
|
261
374
|
it('is idempotent on (received_event_id, subscription_id)', async () => {
|
|
262
375
|
const receivedEventId = await insertReceivedEvent(buildEventParams());
|
|
@@ -376,6 +489,16 @@ describe('decision upserts', () => {
|
|
|
376
489
|
});
|
|
377
490
|
});
|
|
378
491
|
|
|
492
|
+
it('names a NULL-event listener as a source wildcard', async () => {
|
|
493
|
+
const receivedEventId = await insertReceivedEvent(buildEventParams());
|
|
494
|
+
const subscription = buildListenerSubscription({event: null});
|
|
495
|
+
|
|
496
|
+
await upsertListenerTriggeredDecision({receivedEventId, subscription});
|
|
497
|
+
|
|
498
|
+
const rows = await decisionsFor(receivedEventId);
|
|
499
|
+
expect(rows[0]?.subscriptionName).toBe('listener on[0] github/*');
|
|
500
|
+
});
|
|
501
|
+
|
|
379
502
|
it('flips a listener dispatch-error decision to triggered on a successful retry', async () => {
|
|
380
503
|
const receivedEventId = await insertReceivedEvent(buildEventParams());
|
|
381
504
|
const subscription = buildListenerSubscription();
|