@shipfox/api-triggers 12.1.0 → 12.2.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 (31) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +23 -0
  3. package/dist/core/entities/decision.d.ts +1 -1
  4. package/dist/core/entities/decision.d.ts.map +1 -1
  5. package/dist/core/entities/received-event.d.ts +2 -2
  6. package/dist/core/entities/received-event.d.ts.map +1 -1
  7. package/dist/core/errors.d.ts.map +1 -1
  8. package/dist/core/route-event-to-job-listeners.d.ts.map +1 -1
  9. package/dist/core/route-event-to-job-listeners.js +130 -13
  10. package/dist/core/route-event-to-job-listeners.js.map +1 -1
  11. package/dist/core/workflows-client.d.ts +12 -12
  12. package/dist/db/db.d.ts +20 -20
  13. package/dist/db/job-listener-subscriptions.d.ts +1 -0
  14. package/dist/db/job-listener-subscriptions.d.ts.map +1 -1
  15. package/dist/db/job-listener-subscriptions.js +3 -0
  16. package/dist/db/job-listener-subscriptions.js.map +1 -1
  17. package/dist/db/schema/cron-schedules.d.ts +1 -1
  18. package/dist/db/schema/decisions.d.ts +3 -3
  19. package/dist/db/schema/job-listener-subscriptions.d.ts +1 -1
  20. package/dist/db/schema/outbox.d.ts +1 -1
  21. package/dist/db/schema/received-events.d.ts +5 -5
  22. package/dist/db/schema/subscriptions.d.ts +1 -1
  23. package/dist/metrics/instance.d.ts +2 -2
  24. package/dist/presentation/subscribers/on-integration-event-received.d.ts.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +11 -11
  27. package/src/core/route-event-to-job-listeners.test.ts +97 -0
  28. package/src/core/route-event-to-job-listeners.ts +157 -11
  29. package/src/db/job-listener-subscriptions.test.ts +3 -0
  30. package/src/db/job-listener-subscriptions.ts +4 -0
  31. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-triggers",
3
3
  "license": "MIT",
4
- "version": "12.1.0",
4
+ "version": "12.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -22,23 +22,23 @@
22
22
  "cron-parser": "^5.6.1",
23
23
  "drizzle-orm": "^0.45.2",
24
24
  "zod": "^4.4.3",
25
- "@shipfox/api-auth-context": "12.0.0",
26
- "@shipfox/api-definitions-dto": "12.0.0",
27
- "@shipfox/api-integration-core-dto": "12.0.0",
25
+ "@shipfox/api-auth-context": "12.2.0",
26
+ "@shipfox/api-definitions-dto": "12.2.0",
27
+ "@shipfox/api-integration-core-dto": "12.2.0",
28
28
  "@shipfox/api-triggers-dto": "9.0.2",
29
- "@shipfox/api-workflows-dto": "12.1.0",
29
+ "@shipfox/api-workflows-dto": "12.2.0",
30
30
  "@shipfox/config": "1.2.4",
31
- "@shipfox/expression": "2.0.0",
31
+ "@shipfox/expression": "2.1.0",
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",
35
- "@shipfox/node-fastify": "0.4.1",
36
- "@shipfox/node-module": "1.0.5",
37
- "@shipfox/node-opentelemetry": "0.6.3",
35
+ "@shipfox/node-fastify": "0.4.2",
36
+ "@shipfox/node-module": "1.0.6",
37
+ "@shipfox/node-opentelemetry": "0.6.4",
38
38
  "@shipfox/node-outbox": "0.2.6",
39
39
  "@shipfox/node-postgres": "0.5.0",
40
- "@shipfox/node-temporal": "0.4.4",
41
- "@shipfox/workflow-document": "3.0.0"
40
+ "@shipfox/node-temporal": "0.4.5",
41
+ "@shipfox/workflow-document": "3.0.1"
42
42
  },
43
43
  "imports": {
44
44
  "#*": "./dist/*"
@@ -319,6 +319,103 @@ describe('routeEventToJobListeners', () => {
319
319
  expect(result).toMatchObject({engagedCount: 1, matchedJobCount: 1, acceptedJobCount: 1});
320
320
  });
321
321
 
322
+ it.each([
323
+ {
324
+ name: 'integer output',
325
+ filter: 'jobs.build.outputs.count + 1 == 43',
326
+ filterSnapshot: {jobs: {build: {outputs: {count: 42}}}},
327
+ filterOutputTypes: {build: {count: 'int'}},
328
+ },
329
+ {
330
+ name: 'timestamp output',
331
+ filter: 'jobs.build.outputs.createdAt < timestamp("2026-07-01T00:00:00Z")',
332
+ filterSnapshot: {jobs: {build: {outputs: {createdAt: '2026-06-30T12:00:00.000Z'}}}},
333
+ filterOutputTypes: {build: {createdAt: 'timestamp'}},
334
+ },
335
+ {
336
+ name: 'integer execution output',
337
+ filter: 'jobs.build.executions[0].outputs.count + 1 == 43',
338
+ filterSnapshot: {jobs: {build: {executions: [{outputs: {count: 42}}]}}},
339
+ filterOutputTypes: {build: {count: 'int'}},
340
+ },
341
+ {
342
+ name: 'timestamp execution output',
343
+ filter: 'jobs.build.executions[0].outputs.createdAt < timestamp("2026-07-01T00:00:00Z")',
344
+ filterSnapshot: {
345
+ jobs: {build: {executions: [{outputs: {createdAt: '2026-06-30T12:00:00.000Z'}}]}},
346
+ },
347
+ filterOutputTypes: {build: {createdAt: 'timestamp'}},
348
+ },
349
+ {
350
+ name: 'nested output',
351
+ filter: 'jobs.build.outputs.details.count + 1 == 43',
352
+ filterSnapshot: {jobs: {build: {outputs: {details: {count: 42}}}}},
353
+ filterOutputTypes: {
354
+ build: {details: {kind: 'object', fields: {count: 'int'}}},
355
+ },
356
+ },
357
+ ])('rehydrates a $name in listener filters', async ({
358
+ filter,
359
+ filterSnapshot,
360
+ filterOutputTypes,
361
+ }) => {
362
+ const workspaceId = crypto.randomUUID();
363
+ await jobListenerSubscriptionFactory.create({
364
+ workspaceId,
365
+ source: 'github',
366
+ event: 'pull_request_review',
367
+ config: {
368
+ filter,
369
+ filter_snapshot: filterSnapshot,
370
+ filter_output_types: filterOutputTypes,
371
+ },
372
+ });
373
+
374
+ const result = await route({workspaceId});
375
+
376
+ expect(deliverEventToListener).toHaveBeenCalledTimes(1);
377
+ expect(listenerFilterErrored).not.toHaveBeenCalled();
378
+ expect(result).toMatchObject({engagedCount: 1, matchedJobCount: 1, acceptedJobCount: 1});
379
+ });
380
+
381
+ it.each([
382
+ 'on',
383
+ 'until',
384
+ ] as const)('rehydrates CEL scalar values in %s listener filter snapshots', async (kind) => {
385
+ const workspaceId = crypto.randomUUID();
386
+ await jobListenerSubscriptionFactory.create({
387
+ workspaceId,
388
+ kind,
389
+ source: 'github',
390
+ event: 'pull_request_review',
391
+ config: {
392
+ filter:
393
+ 'run.number + 1 == 2 && run.created_at < timestamp("2026-07-01T00:00:00Z") && jobs.build.executions[0].index + 1 == 1 && jobs.build.executions[0].started_at < timestamp("2026-07-01T00:00:00Z") && jobs.build.executions[0].events[0].received_at < timestamp("2026-07-01T00:00:00Z")',
394
+ filter_snapshot: {
395
+ run: {number: 1, created_at: '2026-06-30T12:00:00.000Z'},
396
+ jobs: {
397
+ build: {
398
+ executions: [
399
+ {
400
+ index: 0,
401
+ started_at: '2026-06-30T12:00:00.000Z',
402
+ finished_at: null,
403
+ events: [{received_at: '2026-06-30T12:00:00.000Z'}],
404
+ },
405
+ ],
406
+ },
407
+ },
408
+ },
409
+ },
410
+ });
411
+
412
+ const result = await route({workspaceId});
413
+
414
+ expect(deliverEventToListener).toHaveBeenCalledTimes(1);
415
+ expect(listenerFilterErrored).not.toHaveBeenCalled();
416
+ expect(result).toMatchObject({engagedCount: 1, matchedJobCount: 1, acceptedJobCount: 1});
417
+ });
418
+
322
419
  it('does not deliver or record a decision when a listener filter returns false', async () => {
323
420
  const workspaceId = crypto.randomUUID();
324
421
  await jobListenerSubscriptionFactory.create({
@@ -1,4 +1,8 @@
1
- import {projectWorkflowPredicateContext} from '@shipfox/expression';
1
+ import {
2
+ type ListenerFilterOutputTypes,
3
+ listenerFilterOutputTypesSchema,
4
+ } from '@shipfox/api-workflows-dto';
5
+ import {projectWorkflowPredicateContext, rehydrateJsonExpressionRecord} from '@shipfox/expression';
2
6
  import {logger} from '@shipfox/node-opentelemetry';
3
7
  import {findMatchingJobListenerSubscriptions} from '#db/job-listener-subscriptions.js';
4
8
  import {evaluateStoredFilter, type StoredFilterEvaluation} from './config.js';
@@ -9,6 +13,8 @@ import {
9
13
  type WorkflowsModuleClient,
10
14
  } from './workflows-client.js';
11
15
 
16
+ const LISTENER_INTEGER_PATTERN = /^-?\d+$/;
17
+
12
18
  export interface RouteEventToJobListenersParams {
13
19
  workflows: WorkflowsModuleClient;
14
20
  history: TriggerHistoryRecorder;
@@ -158,7 +164,10 @@ function evaluateListenerFilter(params: EvaluateListenerFilterParams): StoredFil
158
164
  value: filter,
159
165
  context: projectWorkflowPredicateContext(
160
166
  params.subscription.kind === 'on' ? 'listener.on' : 'listener.until',
161
- {...snapshot.value, event: params.payload},
167
+ {
168
+ ...rehydrateListenerFilterSnapshot(snapshot.value, snapshot.outputTypes),
169
+ event: params.payload,
170
+ },
162
171
  ),
163
172
  invalidReason: 'Listener subscription filter must be a non-empty string when set',
164
173
  evaluationFailedReason: 'Listener filter evaluation failed',
@@ -168,22 +177,159 @@ function evaluateListenerFilter(params: EvaluateListenerFilterParams): StoredFil
168
177
  function readFilterSnapshot(
169
178
  subscription: JobListenerSubscription,
170
179
  ):
171
- | {kind: 'valid'; value: Record<string, unknown>}
180
+ | {kind: 'valid'; value: Record<string, unknown>; outputTypes?: ListenerFilterOutputTypes}
172
181
  | {kind: 'invalid'; result: Extract<StoredFilterEvaluation, {kind: 'filter-error'}>} {
173
182
  const value = subscription.config.filter_snapshot;
174
- if (value === undefined) return {kind: 'valid', value: {}};
175
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
176
- return {kind: 'valid', value: value as Record<string, unknown>};
183
+ if (
184
+ value !== undefined &&
185
+ (value === null || typeof value !== 'object' || Array.isArray(value))
186
+ ) {
187
+ return {
188
+ kind: 'invalid',
189
+ result: {
190
+ kind: 'filter-error',
191
+ reason: 'Listener filter snapshot must be an object when set',
192
+ },
193
+ };
194
+ }
195
+
196
+ const outputTypesValue = subscription.config.filter_output_types;
197
+ if (outputTypesValue !== undefined) {
198
+ const parsedOutputTypes = listenerFilterOutputTypesSchema.safeParse(outputTypesValue);
199
+ if (!parsedOutputTypes.success) {
200
+ return {
201
+ kind: 'invalid',
202
+ result: {
203
+ kind: 'filter-error',
204
+ reason: 'Listener filter output types must be valid when set',
205
+ },
206
+ };
207
+ }
208
+ return {
209
+ kind: 'valid',
210
+ value: (value ?? {}) as Record<string, unknown>,
211
+ outputTypes: parsedOutputTypes.data,
212
+ };
177
213
  }
214
+
215
+ return {kind: 'valid', value: (value ?? {}) as Record<string, unknown>};
216
+ }
217
+
218
+ function rehydrateListenerFilterSnapshot(
219
+ snapshot: Record<string, unknown>,
220
+ outputTypes: ListenerFilterOutputTypes | undefined,
221
+ ): Record<string, unknown> {
222
+ let result = snapshot;
223
+ if (outputTypes !== undefined && isRecord(snapshot.jobs)) {
224
+ const jobs = Object.fromEntries(
225
+ Object.entries(snapshot.jobs).map(([jobKey, jobContext]) => {
226
+ const jobOutputTypes = Object.hasOwn(outputTypes, jobKey) ? outputTypes[jobKey] : undefined;
227
+ if (jobOutputTypes === undefined || !isRecord(jobContext)) {
228
+ return [jobKey, jobContext];
229
+ }
230
+
231
+ const outputs = isRecord(jobContext.outputs)
232
+ ? rehydrateJsonExpressionRecord(jobContext.outputs, jobOutputTypes)
233
+ : jobContext.outputs;
234
+ const executions = Array.isArray(jobContext.executions)
235
+ ? jobContext.executions.map((execution) => {
236
+ if (!isRecord(execution) || !isRecord(execution.outputs)) return execution;
237
+ return {
238
+ ...execution,
239
+ outputs: rehydrateJsonExpressionRecord(execution.outputs, jobOutputTypes),
240
+ };
241
+ })
242
+ : jobContext.executions;
243
+
244
+ return [
245
+ jobKey,
246
+ {
247
+ ...jobContext,
248
+ outputs,
249
+ executions,
250
+ },
251
+ ];
252
+ }),
253
+ );
254
+ result = {...snapshot, jobs};
255
+ }
256
+
257
+ const rehydrated = {...result};
258
+ if (isRecord(result.run)) rehydrated.run = rehydrateRunSnapshot(result.run);
259
+ if (isRecord(result.jobs)) rehydrated.jobs = rehydrateJobsSnapshot(result.jobs);
260
+ return rehydrated;
261
+ }
262
+
263
+ function isRecord(value: unknown): value is Record<string, unknown> {
264
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
265
+ }
266
+
267
+ function rehydrateRunSnapshot(run: Record<string, unknown>): Record<string, unknown> {
268
+ const result = {...run};
269
+ if ('number' in run) result.number = rehydrateListenerInt(run.number);
270
+ if ('created_at' in run) result.created_at = rehydrateListenerTimestamp(run.created_at);
271
+ return result;
272
+ }
273
+
274
+ function rehydrateJobsSnapshot(jobs: Record<string, unknown>): Record<string, unknown> {
275
+ return Object.fromEntries(
276
+ Object.entries(jobs).map(([jobKey, value]) => [
277
+ jobKey,
278
+ isRecord(value) ? rehydrateJobSnapshot(value) : value,
279
+ ]),
280
+ );
281
+ }
282
+
283
+ function rehydrateJobSnapshot(job: Record<string, unknown>): Record<string, unknown> {
284
+ if (!Array.isArray(job.executions)) return job;
285
+
178
286
  return {
179
- kind: 'invalid',
180
- result: {
181
- kind: 'filter-error',
182
- reason: 'Listener filter snapshot must be an object when set',
183
- },
287
+ ...job,
288
+ executions: job.executions.map((value) =>
289
+ isRecord(value) ? rehydrateExecutionSnapshot(value) : value,
290
+ ),
184
291
  };
185
292
  }
186
293
 
294
+ function rehydrateExecutionSnapshot(execution: Record<string, unknown>): Record<string, unknown> {
295
+ const result = {...execution};
296
+ if ('index' in execution) result.index = rehydrateListenerInt(execution.index);
297
+ if ('started_at' in execution) {
298
+ result.started_at = rehydrateListenerTimestamp(execution.started_at);
299
+ }
300
+ if ('finished_at' in execution) {
301
+ result.finished_at = rehydrateListenerTimestamp(execution.finished_at);
302
+ }
303
+ if (Array.isArray(execution.events)) {
304
+ result.events = execution.events.map((value) =>
305
+ isRecord(value) ? rehydrateExecutionEventSnapshot(value) : value,
306
+ );
307
+ }
308
+ return result;
309
+ }
310
+
311
+ function rehydrateExecutionEventSnapshot(event: Record<string, unknown>): Record<string, unknown> {
312
+ if (!('received_at' in event)) return event;
313
+ return {...event, received_at: rehydrateListenerTimestamp(event.received_at)};
314
+ }
315
+
316
+ function rehydrateListenerInt(value: unknown): unknown {
317
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return BigInt(value);
318
+ if (typeof value !== 'string' || !LISTENER_INTEGER_PATTERN.test(value)) return value;
319
+
320
+ try {
321
+ return BigInt(value);
322
+ } catch {
323
+ return value;
324
+ }
325
+ }
326
+
327
+ function rehydrateListenerTimestamp(value: unknown): unknown {
328
+ if (typeof value !== 'string') return value;
329
+ const date = new Date(value);
330
+ return Number.isNaN(date.getTime()) ? value : date;
331
+ }
332
+
187
333
  function listenerDisposition(subscription: JobListenerSubscription): 'fire' | 'resolve' {
188
334
  return subscription.kind === 'until' ? 'resolve' : 'fire';
189
335
  }
@@ -123,6 +123,7 @@ describe('job listener subscriptions', () => {
123
123
 
124
124
  it('persists listener filter snapshots verbatim in matcher config', async () => {
125
125
  const filterSnapshot = {jobs: {build: {outputs: {pr_number: 42}}}};
126
+ const filterOutputTypes = {build: {pr_number: 'int' as const}};
126
127
  const payload = buildActivatedPayload({
127
128
  on: [
128
129
  {
@@ -130,6 +131,7 @@ describe('job listener subscriptions', () => {
130
131
  event: 'pull_request_review',
131
132
  filter: 'jobs.build.outputs.pr_number == event.pull_request.number',
132
133
  filter_snapshot: filterSnapshot,
134
+ filter_output_types: filterOutputTypes,
133
135
  },
134
136
  ],
135
137
  until: null,
@@ -142,6 +144,7 @@ describe('job listener subscriptions', () => {
142
144
  expect(rows[0]?.config).toEqual({
143
145
  filter: 'jobs.build.outputs.pr_number == event.pull_request.number',
144
146
  filter_snapshot: filterSnapshot,
147
+ filter_output_types: filterOutputTypes,
145
148
  });
146
149
  });
147
150
 
@@ -17,6 +17,7 @@ export interface ListenerMatcher {
17
17
  inputs?: Readonly<Record<string, unknown>> | undefined;
18
18
  filter?: string | undefined;
19
19
  filter_snapshot?: Readonly<Record<string, unknown>> | undefined;
20
+ filter_output_types?: Readonly<Record<string, unknown>> | undefined;
20
21
  }
21
22
 
22
23
  export interface ProjectJobListenerSubscriptionsParams {
@@ -43,6 +44,9 @@ export async function projectJobListenerSubscriptions(
43
44
  if (matcher.inputs !== undefined) config.inputs = matcher.inputs;
44
45
  if (matcher.filter !== undefined) config.filter = matcher.filter;
45
46
  if (matcher.filter_snapshot !== undefined) config.filter_snapshot = matcher.filter_snapshot;
47
+ if (matcher.filter_output_types !== undefined) {
48
+ config.filter_output_types = matcher.filter_output_types;
49
+ }
46
50
 
47
51
  await tx
48
52
  .insert(jobListenerSubscriptions)