@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
@@ -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": "13.0.0",
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": "12.3.0",
27
- "@shipfox/api-integration-core-dto": "12.2.0",
28
- "@shipfox/api-triggers-dto": "9.0.2",
29
- "@shipfox/api-workflows-dto": "13.0.0",
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.0",
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.1"
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
- subscriptionId: string;
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
- event: string;
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
- event: string;
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',
@@ -47,7 +47,9 @@ export async function fireCronSubscription(
47
47
  workspaceId: subscription.workspaceId,
48
48
  provider: null,
49
49
  source: subscription.source,
50
- event: subscription.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',
@@ -46,7 +46,9 @@ export async function fireManualSubscription(
46
46
  workspaceId: subscription.workspaceId,
47
47
  provider: null,
48
48
  source: subscription.source,
49
- event: subscription.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();