@shipfox/api-triggers 2.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 (331) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +224 -0
  5. package/LICENSE +21 -0
  6. package/README.md +300 -0
  7. package/dist/config.d.ts +10 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +42 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/core/compute-next-fire-at.d.ts +9 -0
  12. package/dist/core/compute-next-fire-at.d.ts.map +1 -0
  13. package/dist/core/compute-next-fire-at.js +28 -0
  14. package/dist/core/compute-next-fire-at.js.map +1 -0
  15. package/dist/core/config.d.ts +26 -0
  16. package/dist/core/config.d.ts.map +1 -0
  17. package/dist/core/config.js +68 -0
  18. package/dist/core/config.js.map +1 -0
  19. package/dist/core/dispatch-integration-event.d.ts +14 -0
  20. package/dist/core/dispatch-integration-event.d.ts.map +1 -0
  21. package/dist/core/dispatch-integration-event.js +136 -0
  22. package/dist/core/dispatch-integration-event.js.map +1 -0
  23. package/dist/core/drain-cron-schedules.d.ts +30 -0
  24. package/dist/core/drain-cron-schedules.d.ts.map +1 -0
  25. package/dist/core/drain-cron-schedules.js +76 -0
  26. package/dist/core/drain-cron-schedules.js.map +1 -0
  27. package/dist/core/entities/cron-schedule.d.ts +11 -0
  28. package/dist/core/entities/cron-schedule.d.ts.map +1 -0
  29. package/dist/core/entities/cron-schedule.js +3 -0
  30. package/dist/core/entities/cron-schedule.js.map +1 -0
  31. package/dist/core/entities/decision.d.ts +23 -0
  32. package/dist/core/entities/decision.d.ts.map +1 -0
  33. package/dist/core/entities/decision.js +7 -0
  34. package/dist/core/entities/decision.js.map +1 -0
  35. package/dist/core/entities/index.d.ts +3 -0
  36. package/dist/core/entities/index.d.ts.map +1 -0
  37. package/dist/core/entities/index.js +3 -0
  38. package/dist/core/entities/index.js.map +1 -0
  39. package/dist/core/entities/job-listener-subscription.d.ts +14 -0
  40. package/dist/core/entities/job-listener-subscription.d.ts.map +1 -0
  41. package/dist/core/entities/job-listener-subscription.js +3 -0
  42. package/dist/core/entities/job-listener-subscription.js.map +1 -0
  43. package/dist/core/entities/received-event.d.ts +28 -0
  44. package/dist/core/entities/received-event.d.ts.map +1 -0
  45. package/dist/core/entities/received-event.js +14 -0
  46. package/dist/core/entities/received-event.js.map +1 -0
  47. package/dist/core/entities/subscription.d.ts +13 -0
  48. package/dist/core/entities/subscription.d.ts.map +1 -0
  49. package/dist/core/entities/subscription.js +3 -0
  50. package/dist/core/entities/subscription.js.map +1 -0
  51. package/dist/core/errors.d.ts +25 -0
  52. package/dist/core/errors.d.ts.map +1 -0
  53. package/dist/core/errors.js +41 -0
  54. package/dist/core/errors.js.map +1 -0
  55. package/dist/core/fire-cron.d.ts +28 -0
  56. package/dist/core/fire-cron.d.ts.map +1 -0
  57. package/dist/core/fire-cron.js +85 -0
  58. package/dist/core/fire-cron.js.map +1 -0
  59. package/dist/core/fire-manual.d.ts +9 -0
  60. package/dist/core/fire-manual.d.ts.map +1 -0
  61. package/dist/core/fire-manual.js +87 -0
  62. package/dist/core/fire-manual.js.map +1 -0
  63. package/dist/core/index.d.ts +11 -0
  64. package/dist/core/index.d.ts.map +1 -0
  65. package/dist/core/index.js +10 -0
  66. package/dist/core/index.js.map +1 -0
  67. package/dist/core/record-trigger-history.d.ts +35 -0
  68. package/dist/core/record-trigger-history.d.ts.map +1 -0
  69. package/dist/core/record-trigger-history.js +75 -0
  70. package/dist/core/record-trigger-history.js.map +1 -0
  71. package/dist/core/route-event-to-job-listeners.d.ts +31 -0
  72. package/dist/core/route-event-to-job-listeners.d.ts.map +1 -0
  73. package/dist/core/route-event-to-job-listeners.js +140 -0
  74. package/dist/core/route-event-to-job-listeners.js.map +1 -0
  75. package/dist/db/cron-schedules.d.ts +36 -0
  76. package/dist/db/cron-schedules.d.ts.map +1 -0
  77. package/dist/db/cron-schedules.js +104 -0
  78. package/dist/db/cron-schedules.js.map +1 -0
  79. package/dist/db/db.d.ts +2515 -0
  80. package/dist/db/db.d.ts.map +1 -0
  81. package/dist/db/db.js +28 -0
  82. package/dist/db/db.js.map +1 -0
  83. package/dist/db/event-history.d.ts +50 -0
  84. package/dist/db/event-history.d.ts.map +1 -0
  85. package/dist/db/event-history.js +207 -0
  86. package/dist/db/event-history.js.map +1 -0
  87. package/dist/db/event-queries.d.ts +38 -0
  88. package/dist/db/event-queries.d.ts.map +1 -0
  89. package/dist/db/event-queries.js +78 -0
  90. package/dist/db/event-queries.js.map +1 -0
  91. package/dist/db/index.d.ts +11 -0
  92. package/dist/db/index.d.ts.map +1 -0
  93. package/dist/db/index.js +14 -0
  94. package/dist/db/index.js.map +1 -0
  95. package/dist/db/job-listener-subscriptions.d.ts +24 -0
  96. package/dist/db/job-listener-subscriptions.d.ts.map +1 -0
  97. package/dist/db/job-listener-subscriptions.js +69 -0
  98. package/dist/db/job-listener-subscriptions.js.map +1 -0
  99. package/dist/db/received-events.d.ts +7 -0
  100. package/dist/db/received-events.d.ts.map +1 -0
  101. package/dist/db/received-events.js +11 -0
  102. package/dist/db/received-events.js.map +1 -0
  103. package/dist/db/schema/common.d.ts +2 -0
  104. package/dist/db/schema/common.d.ts.map +1 -0
  105. package/dist/db/schema/common.js +4 -0
  106. package/dist/db/schema/common.js.map +1 -0
  107. package/dist/db/schema/cron-schedules.d.ts +148 -0
  108. package/dist/db/schema/cron-schedules.d.ts.map +1 -0
  109. package/dist/db/schema/cron-schedules.js +39 -0
  110. package/dist/db/schema/cron-schedules.js.map +1 -0
  111. package/dist/db/schema/decisions.d.ts +284 -0
  112. package/dist/db/schema/decisions.d.ts.map +1 -0
  113. package/dist/db/schema/decisions.js +70 -0
  114. package/dist/db/schema/decisions.js.map +1 -0
  115. package/dist/db/schema/job-listener-subscriptions.d.ts +185 -0
  116. package/dist/db/schema/job-listener-subscriptions.d.ts.map +1 -0
  117. package/dist/db/schema/job-listener-subscriptions.js +41 -0
  118. package/dist/db/schema/job-listener-subscriptions.js.map +1 -0
  119. package/dist/db/schema/outbox.d.ts +195 -0
  120. package/dist/db/schema/outbox.d.ts.map +1 -0
  121. package/dist/db/schema/outbox.js +5 -0
  122. package/dist/db/schema/outbox.js.map +1 -0
  123. package/dist/db/schema/received-events.d.ts +544 -0
  124. package/dist/db/schema/received-events.d.ts.map +1 -0
  125. package/dist/db/schema/received-events.js +86 -0
  126. package/dist/db/schema/received-events.js.map +1 -0
  127. package/dist/db/schema/subscriptions.d.ts +182 -0
  128. package/dist/db/schema/subscriptions.d.ts.map +1 -0
  129. package/dist/db/schema/subscriptions.js +39 -0
  130. package/dist/db/schema/subscriptions.js.map +1 -0
  131. package/dist/db/subscriptions.d.ts +26 -0
  132. package/dist/db/subscriptions.d.ts.map +1 -0
  133. package/dist/db/subscriptions.js +103 -0
  134. package/dist/db/subscriptions.js.map +1 -0
  135. package/dist/index.d.ts +7 -0
  136. package/dist/index.d.ts.map +1 -0
  137. package/dist/index.js +68 -0
  138. package/dist/index.js.map +1 -0
  139. package/dist/metrics/index.d.ts +3 -0
  140. package/dist/metrics/index.d.ts.map +1 -0
  141. package/dist/metrics/index.js +4 -0
  142. package/dist/metrics/index.js.map +1 -0
  143. package/dist/metrics/instance.d.ts +15 -0
  144. package/dist/metrics/instance.d.ts.map +1 -0
  145. package/dist/metrics/instance.js +32 -0
  146. package/dist/metrics/instance.js.map +1 -0
  147. package/dist/metrics/service.d.ts +2 -0
  148. package/dist/metrics/service.d.ts.map +1 -0
  149. package/dist/metrics/service.js +15 -0
  150. package/dist/metrics/service.js.map +1 -0
  151. package/dist/presentation/dto/trigger-events.d.ts +7 -0
  152. package/dist/presentation/dto/trigger-events.d.ts.map +1 -0
  153. package/dist/presentation/dto/trigger-events.js +47 -0
  154. package/dist/presentation/dto/trigger-events.js.map +1 -0
  155. package/dist/presentation/index.d.ts +3 -0
  156. package/dist/presentation/index.d.ts.map +1 -0
  157. package/dist/presentation/index.js +4 -0
  158. package/dist/presentation/index.js.map +1 -0
  159. package/dist/presentation/routes/fire-manual.d.ts +2 -0
  160. package/dist/presentation/routes/fire-manual.d.ts.map +1 -0
  161. package/dist/presentation/routes/fire-manual.js +71 -0
  162. package/dist/presentation/routes/fire-manual.js.map +1 -0
  163. package/dist/presentation/routes/get-trigger-event.d.ts +2 -0
  164. package/dist/presentation/routes/get-trigger-event.d.ts.map +1 -0
  165. package/dist/presentation/routes/get-trigger-event.js +37 -0
  166. package/dist/presentation/routes/get-trigger-event.js.map +1 -0
  167. package/dist/presentation/routes/index.d.ts +3 -0
  168. package/dist/presentation/routes/index.d.ts.map +1 -0
  169. package/dist/presentation/routes/index.js +27 -0
  170. package/dist/presentation/routes/index.js.map +1 -0
  171. package/dist/presentation/routes/list-trigger-event-facets.d.ts +2 -0
  172. package/dist/presentation/routes/list-trigger-event-facets.d.ts.map +1 -0
  173. package/dist/presentation/routes/list-trigger-event-facets.js +29 -0
  174. package/dist/presentation/routes/list-trigger-event-facets.js.map +1 -0
  175. package/dist/presentation/routes/list-trigger-events.d.ts +2 -0
  176. package/dist/presentation/routes/list-trigger-events.d.ts.map +1 -0
  177. package/dist/presentation/routes/list-trigger-events.js +56 -0
  178. package/dist/presentation/routes/list-trigger-events.js.map +1 -0
  179. package/dist/presentation/subscribers/index.d.ts +6 -0
  180. package/dist/presentation/subscribers/index.d.ts.map +1 -0
  181. package/dist/presentation/subscribers/index.js +7 -0
  182. package/dist/presentation/subscribers/index.js.map +1 -0
  183. package/dist/presentation/subscribers/on-definition-deleted.d.ts +3 -0
  184. package/dist/presentation/subscribers/on-definition-deleted.d.ts.map +1 -0
  185. package/dist/presentation/subscribers/on-definition-deleted.js +8 -0
  186. package/dist/presentation/subscribers/on-definition-deleted.js.map +1 -0
  187. package/dist/presentation/subscribers/on-definition-resolved.d.ts +3 -0
  188. package/dist/presentation/subscribers/on-definition-resolved.d.ts.map +1 -0
  189. package/dist/presentation/subscribers/on-definition-resolved.js +11 -0
  190. package/dist/presentation/subscribers/on-definition-resolved.js.map +1 -0
  191. package/dist/presentation/subscribers/on-integration-event-received.d.ts +4 -0
  192. package/dist/presentation/subscribers/on-integration-event-received.d.ts.map +1 -0
  193. package/dist/presentation/subscribers/on-integration-event-received.js +17 -0
  194. package/dist/presentation/subscribers/on-integration-event-received.js.map +1 -0
  195. package/dist/presentation/subscribers/on-job-activated.d.ts +3 -0
  196. package/dist/presentation/subscribers/on-job-activated.d.ts.map +1 -0
  197. package/dist/presentation/subscribers/on-job-activated.js +13 -0
  198. package/dist/presentation/subscribers/on-job-activated.js.map +1 -0
  199. package/dist/presentation/subscribers/on-job-terminated.d.ts +3 -0
  200. package/dist/presentation/subscribers/on-job-terminated.d.ts.map +1 -0
  201. package/dist/presentation/subscribers/on-job-terminated.js +6 -0
  202. package/dist/presentation/subscribers/on-job-terminated.js.map +1 -0
  203. package/dist/temporal/activities/drain-cron-batch.d.ts +4 -0
  204. package/dist/temporal/activities/drain-cron-batch.d.ts.map +1 -0
  205. package/dist/temporal/activities/drain-cron-batch.js +20 -0
  206. package/dist/temporal/activities/drain-cron-batch.js.map +1 -0
  207. package/dist/temporal/activities/index.d.ts +10 -0
  208. package/dist/temporal/activities/index.d.ts.map +1 -0
  209. package/dist/temporal/activities/index.js +15 -0
  210. package/dist/temporal/activities/index.js.map +1 -0
  211. package/dist/temporal/activities/prune-trigger-events.d.ts +4 -0
  212. package/dist/temporal/activities/prune-trigger-events.d.ts.map +1 -0
  213. package/dist/temporal/activities/prune-trigger-events.js +11 -0
  214. package/dist/temporal/activities/prune-trigger-events.js.map +1 -0
  215. package/dist/temporal/constants.d.ts +7 -0
  216. package/dist/temporal/constants.d.ts.map +1 -0
  217. package/dist/temporal/constants.js +7 -0
  218. package/dist/temporal/constants.js.map +1 -0
  219. package/dist/temporal/workflows/cron-tick-cron.d.ts +9 -0
  220. package/dist/temporal/workflows/cron-tick-cron.d.ts.map +1 -0
  221. package/dist/temporal/workflows/cron-tick-cron.js +54 -0
  222. package/dist/temporal/workflows/cron-tick-cron.js.map +1 -0
  223. package/dist/temporal/workflows/index.d.ts +3 -0
  224. package/dist/temporal/workflows/index.d.ts.map +1 -0
  225. package/dist/temporal/workflows/index.js +4 -0
  226. package/dist/temporal/workflows/index.js.map +1 -0
  227. package/dist/temporal/workflows/prune-trigger-events-cron.d.ts +2 -0
  228. package/dist/temporal/workflows/prune-trigger-events-cron.d.ts.map +1 -0
  229. package/dist/temporal/workflows/prune-trigger-events-cron.js +18 -0
  230. package/dist/temporal/workflows/prune-trigger-events-cron.js.map +1 -0
  231. package/dist/tsconfig.test.tsbuildinfo +1 -0
  232. package/drizzle/0000_initial.sql +109 -0
  233. package/drizzle/meta/0000_snapshot.json +905 -0
  234. package/drizzle/meta/_journal.json +13 -0
  235. package/drizzle.config.ts +14 -0
  236. package/package.json +83 -0
  237. package/src/config.test.ts +33 -0
  238. package/src/config.ts +53 -0
  239. package/src/core/compute-next-fire-at.test.ts +69 -0
  240. package/src/core/compute-next-fire-at.ts +37 -0
  241. package/src/core/config.test.ts +213 -0
  242. package/src/core/config.ts +86 -0
  243. package/src/core/dispatch-integration-event.history-failure.test.ts +66 -0
  244. package/src/core/dispatch-integration-event.test.ts +1006 -0
  245. package/src/core/dispatch-integration-event.ts +150 -0
  246. package/src/core/drain-cron-schedules.test.ts +265 -0
  247. package/src/core/drain-cron-schedules.ts +90 -0
  248. package/src/core/entities/cron-schedule.ts +10 -0
  249. package/src/core/entities/decision.ts +24 -0
  250. package/src/core/entities/index.ts +5 -0
  251. package/src/core/entities/job-listener-subscription.ts +14 -0
  252. package/src/core/entities/received-event.ts +36 -0
  253. package/src/core/entities/subscription.ts +12 -0
  254. package/src/core/errors.ts +63 -0
  255. package/src/core/fire-cron.test.ts +146 -0
  256. package/src/core/fire-cron.ts +98 -0
  257. package/src/core/fire-manual.test.ts +157 -0
  258. package/src/core/fire-manual.ts +92 -0
  259. package/src/core/index.ts +34 -0
  260. package/src/core/record-trigger-history.test.ts +159 -0
  261. package/src/core/record-trigger-history.ts +145 -0
  262. package/src/core/route-event-to-job-listeners.test.ts +369 -0
  263. package/src/core/route-event-to-job-listeners.ts +184 -0
  264. package/src/db/cron-schedules.test.ts +358 -0
  265. package/src/db/cron-schedules.ts +163 -0
  266. package/src/db/db.ts +31 -0
  267. package/src/db/event-history.test.ts +421 -0
  268. package/src/db/event-history.ts +279 -0
  269. package/src/db/event-queries.ts +140 -0
  270. package/src/db/index.ts +51 -0
  271. package/src/db/job-listener-subscriptions.test.ts +270 -0
  272. package/src/db/job-listener-subscriptions.ts +133 -0
  273. package/src/db/received-events.ts +16 -0
  274. package/src/db/schema/common.ts +3 -0
  275. package/src/db/schema/cron-schedules.ts +37 -0
  276. package/src/db/schema/decisions.test.ts +279 -0
  277. package/src/db/schema/decisions.ts +77 -0
  278. package/src/db/schema/job-listener-subscriptions.ts +65 -0
  279. package/src/db/schema/outbox.ts +4 -0
  280. package/src/db/schema/received-events.test.ts +135 -0
  281. package/src/db/schema/received-events.ts +93 -0
  282. package/src/db/schema/subscriptions.ts +46 -0
  283. package/src/db/subscriptions.test.ts +340 -0
  284. package/src/db/subscriptions.ts +179 -0
  285. package/src/index.ts +108 -0
  286. package/src/metrics/index.ts +8 -0
  287. package/src/metrics/instance.ts +33 -0
  288. package/src/metrics/service.ts +18 -0
  289. package/src/presentation/dto/trigger-events.test.ts +144 -0
  290. package/src/presentation/dto/trigger-events.ts +60 -0
  291. package/src/presentation/index.ts +6 -0
  292. package/src/presentation/routes/fire-manual.test.ts +118 -0
  293. package/src/presentation/routes/fire-manual.ts +70 -0
  294. package/src/presentation/routes/get-trigger-event.test.ts +184 -0
  295. package/src/presentation/routes/get-trigger-event.ts +37 -0
  296. package/src/presentation/routes/index.ts +21 -0
  297. package/src/presentation/routes/list-trigger-event-facets.test.ts +116 -0
  298. package/src/presentation/routes/list-trigger-event-facets.ts +29 -0
  299. package/src/presentation/routes/list-trigger-events.test.ts +379 -0
  300. package/src/presentation/routes/list-trigger-events.ts +68 -0
  301. package/src/presentation/subscribers/index.ts +5 -0
  302. package/src/presentation/subscribers/on-definition-deleted.test.ts +42 -0
  303. package/src/presentation/subscribers/on-definition-deleted.ts +6 -0
  304. package/src/presentation/subscribers/on-definition-resolved.test.ts +67 -0
  305. package/src/presentation/subscribers/on-definition-resolved.ts +11 -0
  306. package/src/presentation/subscribers/on-integration-event-received.test.ts +53 -0
  307. package/src/presentation/subscribers/on-integration-event-received.ts +21 -0
  308. package/src/presentation/subscribers/on-job-activated.ts +14 -0
  309. package/src/presentation/subscribers/on-job-terminated.ts +6 -0
  310. package/src/temporal/activities/drain-cron-batch.ts +20 -0
  311. package/src/temporal/activities/index.ts +15 -0
  312. package/src/temporal/activities/prune-trigger-events.test.ts +78 -0
  313. package/src/temporal/activities/prune-trigger-events.ts +9 -0
  314. package/src/temporal/constants.ts +7 -0
  315. package/src/temporal/workflows/cron-tick-cron.ts +56 -0
  316. package/src/temporal/workflows/index.ts +2 -0
  317. package/src/temporal/workflows/prune-trigger-events-cron.ts +17 -0
  318. package/test/env.ts +8 -0
  319. package/test/factories/cron-schedule.ts +34 -0
  320. package/test/factories/decision.ts +49 -0
  321. package/test/factories/job-listener-subscription.ts +42 -0
  322. package/test/factories/received-event.ts +49 -0
  323. package/test/factories/subscription.ts +38 -0
  324. package/test/globalSetup.ts +20 -0
  325. package/test/index.ts +5 -0
  326. package/test/setup.ts +17 -0
  327. package/tsconfig.build.json +9 -0
  328. package/tsconfig.build.tsbuildinfo +1 -0
  329. package/tsconfig.json +3 -0
  330. package/tsconfig.test.json +8 -0
  331. package/vitest.config.ts +11 -0
@@ -0,0 +1,145 @@
1
+ import {logger} from '@shipfox/node-opentelemetry';
2
+ import type {JobListenerSubscription} from '#core/entities/job-listener-subscription.js';
3
+ import type {TriggerEventOrigin} from '#core/entities/received-event.js';
4
+ import type {TriggerSubscription} from '#core/entities/subscription.js';
5
+ import {
6
+ insertReceivedEvent,
7
+ markReceivedEventDiscarded,
8
+ markReceivedEventErrored,
9
+ markReceivedEventFailed,
10
+ markReceivedEventRouted,
11
+ upsertDispatchErrorDecision,
12
+ upsertFilterErrorDecision,
13
+ upsertListenerDispatchErrorDecision,
14
+ upsertListenerFilterErrorDecision,
15
+ upsertListenerTriggeredDecision,
16
+ upsertTriggeredDecision,
17
+ } from '#db/event-history.js';
18
+
19
+ const MAX_REASON_LENGTH = 2000;
20
+
21
+ // A bounded, deterministic reason string safe to persist: error messages can be
22
+ // long or carry untrusted data, so cap the length and never serialize the object.
23
+ export function toReason(error: unknown): string {
24
+ let message: string;
25
+ try {
26
+ message = error instanceof Error ? error.message : String(error);
27
+ } catch {
28
+ message = '[unprintable thrown value]';
29
+ }
30
+ return message.slice(0, MAX_REASON_LENGTH);
31
+ }
32
+
33
+ export interface TriggerRun {
34
+ id: string;
35
+ name: string;
36
+ }
37
+
38
+ export interface TriggerHistoryRecorder {
39
+ triggered(subscription: TriggerSubscription, run: TriggerRun): Promise<void>;
40
+ filterErrored(subscription: TriggerSubscription, reason: string): Promise<void>;
41
+ dispatchErrored(subscription: TriggerSubscription, reason: string): Promise<void>;
42
+ listenerTriggered(subscription: JobListenerSubscription): Promise<void>;
43
+ listenerFilterErrored(subscription: JobListenerSubscription, reason: string): Promise<void>;
44
+ listenerDispatchErrored(subscription: JobListenerSubscription, reason: string): Promise<void>;
45
+ discarded(): Promise<void>;
46
+ routed(matchedCount: number): Promise<void>;
47
+ failed(matchedCount: number): Promise<void>;
48
+ allErrored(matchedCount: number): Promise<void>;
49
+ }
50
+
51
+ export interface BeginTriggerHistoryParams {
52
+ eventRef: string;
53
+ origin: TriggerEventOrigin;
54
+ workspaceId: string;
55
+ provider: string | null;
56
+ source: string;
57
+ event: string;
58
+ deliveryId: string | null;
59
+ connectionId: string | null;
60
+ connectionName: string | null;
61
+ payload: Record<string, unknown> | null;
62
+ receivedAt: Date;
63
+ }
64
+
65
+ // History writes are best-effort and log stable ids only, never payloads or tokens.
66
+ // If the parent insert fails there is no row to attach to, so later calls no-op.
67
+ export async function beginTriggerHistory(
68
+ params: BeginTriggerHistoryParams,
69
+ ): Promise<TriggerHistoryRecorder> {
70
+ const receivedEventId = await safe(params.eventRef, 'insert-received-event', () =>
71
+ insertReceivedEvent(params),
72
+ );
73
+
74
+ const record = async (
75
+ label: string,
76
+ write: (receivedEventId: string) => Promise<unknown>,
77
+ subscriptionId?: string,
78
+ ): Promise<void> => {
79
+ if (receivedEventId === undefined) return;
80
+ await safe(params.eventRef, label, () => write(receivedEventId), subscriptionId);
81
+ };
82
+
83
+ return {
84
+ triggered: (subscription, run) =>
85
+ record(
86
+ 'triggered-decision',
87
+ (id) => upsertTriggeredDecision({receivedEventId: id, subscription, run}),
88
+ subscription.id,
89
+ ),
90
+ filterErrored: (subscription, reason) =>
91
+ record(
92
+ 'filter-error-decision',
93
+ (id) => upsertFilterErrorDecision({receivedEventId: id, subscription, reason}),
94
+ subscription.id,
95
+ ),
96
+ dispatchErrored: (subscription, reason) =>
97
+ record(
98
+ 'dispatch-error-decision',
99
+ (id) => upsertDispatchErrorDecision({receivedEventId: id, subscription, reason}),
100
+ subscription.id,
101
+ ),
102
+ listenerTriggered: (subscription) =>
103
+ record(
104
+ 'listener-triggered-decision',
105
+ (id) => upsertListenerTriggeredDecision({receivedEventId: id, subscription}),
106
+ subscription.id,
107
+ ),
108
+ listenerFilterErrored: (subscription, reason) =>
109
+ record(
110
+ 'listener-filter-error-decision',
111
+ (id) => upsertListenerFilterErrorDecision({receivedEventId: id, subscription, reason}),
112
+ subscription.id,
113
+ ),
114
+ listenerDispatchErrored: (subscription, reason) =>
115
+ record(
116
+ 'listener-dispatch-error-decision',
117
+ (id) => upsertListenerDispatchErrorDecision({receivedEventId: id, subscription, reason}),
118
+ subscription.id,
119
+ ),
120
+ discarded: () => record('discard-event', (id) => markReceivedEventDiscarded(id)),
121
+ routed: (matchedCount) =>
122
+ record('route-event', (id) => markReceivedEventRouted(id, matchedCount)),
123
+ failed: (matchedCount) =>
124
+ record('fail-event', (id) => markReceivedEventFailed(id, matchedCount)),
125
+ allErrored: (matchedCount) =>
126
+ record('all-errored-event', (id) => markReceivedEventErrored(id, matchedCount)),
127
+ };
128
+ }
129
+
130
+ async function safe<T>(
131
+ eventRef: string,
132
+ label: string,
133
+ fn: () => Promise<T>,
134
+ subscriptionId?: string,
135
+ ): Promise<T | undefined> {
136
+ try {
137
+ return await fn();
138
+ } catch (error) {
139
+ logger().warn(
140
+ {err: error, label, eventRef, ...(subscriptionId ? {subscriptionId} : {})},
141
+ 'trigger history write failed; ignored (best-effort)',
142
+ );
143
+ return undefined;
144
+ }
145
+ }
@@ -0,0 +1,369 @@
1
+ import {jobListenerSubscriptionFactory} from '#test/index.js';
2
+ import type {TriggerHistoryRecorder} from './record-trigger-history.js';
3
+
4
+ const deliverEventToListener = vi.fn();
5
+ const findMatchingJobListenerSubscriptions = vi.fn();
6
+ const listenerTriggered = vi.fn();
7
+ const listenerFilterErrored = vi.fn();
8
+ const listenerDispatchErrored = vi.fn();
9
+ const loggerWarn = vi.fn();
10
+
11
+ vi.mock('@shipfox/api-workflows', () => ({
12
+ deliverEventToListener: (...args: unknown[]) => deliverEventToListener(...args),
13
+ }));
14
+
15
+ vi.mock('@shipfox/node-opentelemetry', () => ({
16
+ logger: () => ({warn: loggerWarn}),
17
+ }));
18
+
19
+ vi.mock('#db/job-listener-subscriptions.js', async (importOriginal) => {
20
+ const actual = await importOriginal<typeof import('#db/job-listener-subscriptions.js')>();
21
+ return {
22
+ ...actual,
23
+ findMatchingJobListenerSubscriptions: (
24
+ ...args: Parameters<typeof actual.findMatchingJobListenerSubscriptions>
25
+ ) => {
26
+ if (findMatchingJobListenerSubscriptions.getMockImplementation()) {
27
+ return findMatchingJobListenerSubscriptions(...args);
28
+ }
29
+ return actual.findMatchingJobListenerSubscriptions(...args);
30
+ },
31
+ };
32
+ });
33
+
34
+ const {routeEventToJobListeners} = await import('./route-event-to-job-listeners.js');
35
+
36
+ interface RouteOverrides {
37
+ eventRef?: string;
38
+ workspaceId?: string;
39
+ source?: string;
40
+ event?: string;
41
+ payload?: unknown;
42
+ }
43
+
44
+ function route(overrides: RouteOverrides = {}) {
45
+ return routeEventToJobListeners({
46
+ history: buildHistory(),
47
+ eventRef: overrides.eventRef ?? crypto.randomUUID(),
48
+ workspaceId: overrides.workspaceId ?? crypto.randomUUID(),
49
+ provider: 'github',
50
+ source: overrides.source ?? 'github',
51
+ event: overrides.event ?? 'pull_request_review',
52
+ deliveryId: crypto.randomUUID(),
53
+ payload: overrides.payload ?? {action: 'submitted'},
54
+ receivedAt: new Date('2026-01-01T00:00:00.000Z'),
55
+ });
56
+ }
57
+
58
+ function buildHistory(): TriggerHistoryRecorder {
59
+ return {
60
+ triggered: vi.fn(),
61
+ filterErrored: vi.fn(),
62
+ dispatchErrored: vi.fn(),
63
+ listenerTriggered,
64
+ listenerFilterErrored,
65
+ listenerDispatchErrored,
66
+ discarded: vi.fn(),
67
+ routed: vi.fn(),
68
+ failed: vi.fn(),
69
+ allErrored: vi.fn(),
70
+ };
71
+ }
72
+
73
+ describe('routeEventToJobListeners', () => {
74
+ beforeEach(() => {
75
+ deliverEventToListener.mockReset();
76
+ findMatchingJobListenerSubscriptions.mockReset();
77
+ listenerTriggered.mockReset();
78
+ listenerFilterErrored.mockReset();
79
+ listenerDispatchErrored.mockReset();
80
+ loggerWarn.mockReset();
81
+ deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
82
+ });
83
+
84
+ it('computes resolve as the effective disposition when on and until match one job', async () => {
85
+ const workspaceId = crypto.randomUUID();
86
+ const jobId = crypto.randomUUID();
87
+ await jobListenerSubscriptionFactory.create({
88
+ workspaceId,
89
+ jobId,
90
+ kind: 'on',
91
+ matcherOrdinal: 0,
92
+ source: 'github',
93
+ event: 'pull_request_review',
94
+ });
95
+ await jobListenerSubscriptionFactory.create({
96
+ workspaceId,
97
+ jobId,
98
+ kind: 'until',
99
+ matcherOrdinal: 0,
100
+ source: 'github',
101
+ event: 'pull_request_review',
102
+ });
103
+
104
+ const result = await route({workspaceId});
105
+
106
+ expect(deliverEventToListener).toHaveBeenCalledTimes(1);
107
+ expect(deliverEventToListener).toHaveBeenCalledWith(
108
+ expect.objectContaining({jobId, disposition: 'resolve'}),
109
+ );
110
+ expect(listenerTriggered).toHaveBeenCalledTimes(1);
111
+ expect(listenerTriggered).toHaveBeenCalledWith(expect.objectContaining({kind: 'until'}));
112
+ expect(result).toMatchObject({
113
+ engagedCount: 1,
114
+ matchedJobCount: 1,
115
+ acceptedJobCount: 1,
116
+ deliveredCount: 1,
117
+ transientErrored: false,
118
+ });
119
+ });
120
+
121
+ it('does not deliver when no listener subscription matches', async () => {
122
+ const workspaceId = crypto.randomUUID();
123
+ await jobListenerSubscriptionFactory.create({
124
+ workspaceId,
125
+ source: 'github',
126
+ event: 'push',
127
+ });
128
+
129
+ const result = await route({workspaceId, event: 'pull_request'});
130
+
131
+ expect(deliverEventToListener).not.toHaveBeenCalled();
132
+ expect(result).toMatchObject({
133
+ engagedCount: 0,
134
+ matchedJobCount: 0,
135
+ acceptedJobCount: 0,
136
+ deliveredCount: 0,
137
+ transientErrored: false,
138
+ });
139
+ });
140
+
141
+ it('does not count skipped stale subscriptions as accepted deliveries', async () => {
142
+ const workspaceId = crypto.randomUUID();
143
+ await jobListenerSubscriptionFactory.create({
144
+ workspaceId,
145
+ source: 'github',
146
+ event: 'pull_request_review',
147
+ });
148
+ deliverEventToListener.mockResolvedValueOnce({buffered: false, skipped: true});
149
+
150
+ const result = await route({workspaceId});
151
+
152
+ expect(result).toMatchObject({
153
+ engagedCount: 0,
154
+ matchedJobCount: 1,
155
+ acceptedJobCount: 0,
156
+ deliveredCount: 0,
157
+ transientErrored: false,
158
+ });
159
+ });
160
+
161
+ it('surfaces transient delivery errors after attempting every matched job', async () => {
162
+ const workspaceId = crypto.randomUUID();
163
+ await jobListenerSubscriptionFactory.create({
164
+ workspaceId,
165
+ jobId: crypto.randomUUID(),
166
+ source: 'github',
167
+ event: 'pull_request_review',
168
+ });
169
+ await jobListenerSubscriptionFactory.create({
170
+ workspaceId,
171
+ jobId: crypto.randomUUID(),
172
+ source: 'github',
173
+ event: 'pull_request_review',
174
+ });
175
+ const error = new Error('workflow db down');
176
+ deliverEventToListener.mockRejectedValueOnce(error);
177
+
178
+ const result = await route({workspaceId});
179
+
180
+ expect(deliverEventToListener).toHaveBeenCalledTimes(2);
181
+ expect(listenerDispatchErrored).toHaveBeenCalledTimes(1);
182
+ expect(listenerDispatchErrored).toHaveBeenCalledWith(
183
+ expect.objectContaining({source: 'github', event: 'pull_request_review'}),
184
+ 'workflow db down',
185
+ );
186
+ expect(listenerTriggered).toHaveBeenCalledTimes(1);
187
+ expect(result).toMatchObject({
188
+ engagedCount: 2,
189
+ matchedJobCount: 2,
190
+ acceptedJobCount: 1,
191
+ deliveredCount: 1,
192
+ transientErrored: true,
193
+ transientError: error,
194
+ });
195
+ });
196
+
197
+ it('delivers only when a listener filter matches the payload and snapshot', async () => {
198
+ const workspaceId = crypto.randomUUID();
199
+ const jobId = crypto.randomUUID();
200
+ await jobListenerSubscriptionFactory.create({
201
+ workspaceId,
202
+ jobId,
203
+ source: 'github',
204
+ event: 'pull_request_review',
205
+ config: {
206
+ filter: 'event.issue.number == jobs.build.outputs.pr_number',
207
+ filter_snapshot: {jobs: {build: {outputs: {pr_number: 42}}}},
208
+ },
209
+ });
210
+
211
+ const result = await route({workspaceId, payload: {issue: {number: 42}}});
212
+
213
+ expect(deliverEventToListener).toHaveBeenCalledTimes(1);
214
+ expect(listenerTriggered).toHaveBeenCalledTimes(1);
215
+ expect(result).toMatchObject({engagedCount: 1, matchedJobCount: 1, acceptedJobCount: 1});
216
+ });
217
+
218
+ it('does not deliver or record a decision when a listener filter returns false', async () => {
219
+ const workspaceId = crypto.randomUUID();
220
+ await jobListenerSubscriptionFactory.create({
221
+ workspaceId,
222
+ source: 'github',
223
+ event: 'pull_request_review',
224
+ config: {
225
+ filter: 'event.issue.number == jobs.build.outputs.pr_number',
226
+ filter_snapshot: {jobs: {build: {outputs: {pr_number: 42}}}},
227
+ },
228
+ });
229
+
230
+ const result = await route({workspaceId, payload: {issue: {number: 7}}});
231
+
232
+ expect(deliverEventToListener).not.toHaveBeenCalled();
233
+ expect(listenerTriggered).not.toHaveBeenCalled();
234
+ expect(listenerFilterErrored).not.toHaveBeenCalled();
235
+ expect(result).toMatchObject({engagedCount: 0, matchedJobCount: 0, acceptedJobCount: 0});
236
+ });
237
+
238
+ it.each([
239
+ {name: 'missing jobs snapshot', filterSnapshot: {}},
240
+ {name: 'empty jobs snapshot', filterSnapshot: {jobs: {}}},
241
+ ])('records filter-error when listener filter evaluation fails with $name', async ({
242
+ filterSnapshot,
243
+ }) => {
244
+ const workspaceId = crypto.randomUUID();
245
+ await jobListenerSubscriptionFactory.create({
246
+ workspaceId,
247
+ source: 'github',
248
+ event: 'pull_request_review',
249
+ config: {
250
+ filter: 'event.issue.number == jobs.build.outputs.pr_number',
251
+ filter_snapshot: filterSnapshot,
252
+ },
253
+ });
254
+
255
+ const result = await route({workspaceId, payload: {issue: {number: 42}}});
256
+
257
+ expect(deliverEventToListener).not.toHaveBeenCalled();
258
+ expect(listenerFilterErrored).toHaveBeenCalledTimes(1);
259
+ expect(listenerFilterErrored).toHaveBeenCalledWith(
260
+ expect.objectContaining({source: 'github', event: 'pull_request_review'}),
261
+ 'Listener filter evaluation failed',
262
+ );
263
+ expect(result).toMatchObject({engagedCount: 1, matchedJobCount: 0, acceptedJobCount: 0});
264
+ });
265
+
266
+ it('records filter-error when a listener snapshot is malformed', async () => {
267
+ const workspaceId = crypto.randomUUID();
268
+ await jobListenerSubscriptionFactory.create({
269
+ workspaceId,
270
+ source: 'github',
271
+ event: 'pull_request_review',
272
+ config: {
273
+ filter: 'event.issue.number == jobs.build.outputs.pr_number',
274
+ filter_snapshot: ['not-object'],
275
+ },
276
+ });
277
+
278
+ const result = await route({workspaceId, payload: {issue: {number: 42}}});
279
+
280
+ expect(deliverEventToListener).not.toHaveBeenCalled();
281
+ expect(listenerFilterErrored).toHaveBeenCalledWith(
282
+ expect.anything(),
283
+ 'Listener filter snapshot must be an object when set',
284
+ );
285
+ expect(result).toMatchObject({engagedCount: 1, matchedJobCount: 0});
286
+ });
287
+
288
+ it('chooses the lowest ordinal when multiple same-kind matchers pass for one job', async () => {
289
+ const workspaceId = crypto.randomUUID();
290
+ const jobId = crypto.randomUUID();
291
+ await jobListenerSubscriptionFactory.create({
292
+ workspaceId,
293
+ jobId,
294
+ kind: 'on',
295
+ matcherOrdinal: 1,
296
+ source: 'github',
297
+ event: 'pull_request_review',
298
+ });
299
+ await jobListenerSubscriptionFactory.create({
300
+ workspaceId,
301
+ jobId,
302
+ kind: 'on',
303
+ matcherOrdinal: 0,
304
+ source: 'github',
305
+ event: 'pull_request_review',
306
+ });
307
+
308
+ await route({workspaceId});
309
+
310
+ expect(deliverEventToListener).toHaveBeenCalledTimes(1);
311
+ expect(listenerTriggered).toHaveBeenCalledWith(
312
+ expect.objectContaining({jobId, kind: 'on', matcherOrdinal: 0}),
313
+ );
314
+ });
315
+
316
+ it('chooses a deterministic subscription id order for duplicate matcher keys', async () => {
317
+ const workspaceId = crypto.randomUUID();
318
+ const jobId = crypto.randomUUID();
319
+ const kept = jobListenerSubscriptionFactory.build({
320
+ id: '00000000-0000-7000-8000-000000000001',
321
+ workspaceId,
322
+ jobId,
323
+ kind: 'on',
324
+ matcherOrdinal: 0,
325
+ source: 'github',
326
+ event: 'pull_request_review',
327
+ });
328
+ const first = jobListenerSubscriptionFactory.build({
329
+ id: '00000000-0000-7000-8000-000000000002',
330
+ workspaceId,
331
+ jobId,
332
+ kind: 'on',
333
+ matcherOrdinal: 0,
334
+ source: 'github',
335
+ event: 'pull_request_review',
336
+ });
337
+ findMatchingJobListenerSubscriptions.mockResolvedValueOnce([first, kept]);
338
+
339
+ const result = await route({workspaceId});
340
+
341
+ expect(loggerWarn).toHaveBeenCalledWith(
342
+ expect.objectContaining({
343
+ jobId,
344
+ keptSubscriptionId: first.id,
345
+ candidateSubscriptionId: kept.id,
346
+ matcherKind: 'on',
347
+ matcherOrdinal: 0,
348
+ }),
349
+ 'duplicate job listener matcher key encountered; choosing deterministic subscription id order',
350
+ );
351
+ expect(listenerTriggered).toHaveBeenCalledWith(expect.objectContaining({id: kept.id}));
352
+ expect(result).toMatchObject({engagedCount: 1, matchedJobCount: 1, acceptedJobCount: 1});
353
+ });
354
+
355
+ it('does not record triggered for skipped stale listener jobs', async () => {
356
+ const workspaceId = crypto.randomUUID();
357
+ await jobListenerSubscriptionFactory.create({
358
+ workspaceId,
359
+ source: 'github',
360
+ event: 'pull_request_review',
361
+ });
362
+ deliverEventToListener.mockResolvedValueOnce({buffered: false, skipped: true});
363
+
364
+ const result = await route({workspaceId});
365
+
366
+ expect(listenerTriggered).not.toHaveBeenCalled();
367
+ expect(result).toMatchObject({engagedCount: 0, matchedJobCount: 1, acceptedJobCount: 0});
368
+ });
369
+ });
@@ -0,0 +1,184 @@
1
+ import {deliverEventToListener, type JobListenerEventDisposition} from '@shipfox/api-workflows';
2
+ import {logger} from '@shipfox/node-opentelemetry';
3
+ import {findMatchingJobListenerSubscriptions} from '#db/job-listener-subscriptions.js';
4
+ import {evaluateStoredFilter, type StoredFilterEvaluation} from './config.js';
5
+ import type {JobListenerSubscription} from './entities/job-listener-subscription.js';
6
+ import {type TriggerHistoryRecorder, toReason} from './record-trigger-history.js';
7
+
8
+ export interface RouteEventToJobListenersParams {
9
+ history: TriggerHistoryRecorder;
10
+ eventRef: string;
11
+ workspaceId: string;
12
+ provider: string;
13
+ source: string;
14
+ event: string;
15
+ deliveryId: string;
16
+ payload: unknown;
17
+ receivedAt: Date;
18
+ }
19
+
20
+ export interface RouteEventToJobListenersResult {
21
+ /**
22
+ * Listener outcomes that should contribute to received_event.matched_count:
23
+ * filter errors, accepted deliveries, and dispatch errors. Stale skipped jobs
24
+ * are intentionally excluded because they did not produce an auditable decision.
25
+ */
26
+ engagedCount: number;
27
+ /**
28
+ * Jobs with an effective matcher after source/event/filter checks, including
29
+ * stale skipped jobs.
30
+ */
31
+ matchedJobCount: number;
32
+ acceptedJobCount: number;
33
+ deliveredCount: number;
34
+ transientErrored: boolean;
35
+ transientError: unknown;
36
+ }
37
+
38
+ export async function routeEventToJobListeners(
39
+ params: RouteEventToJobListenersParams,
40
+ ): Promise<RouteEventToJobListenersResult> {
41
+ const subscriptions = await findMatchingJobListenerSubscriptions({
42
+ workspaceId: params.workspaceId,
43
+ source: params.source,
44
+ event: params.event,
45
+ });
46
+
47
+ let filterErrorCount = 0;
48
+ const effectiveMatchByJobId = new Map<string, JobListenerSubscription>();
49
+ for (const subscription of subscriptions) {
50
+ const filterResult = evaluateListenerFilter({subscription, payload: params.payload});
51
+ if (filterResult.kind === 'filtered') continue;
52
+ if (filterResult.kind === 'filter-error') {
53
+ filterErrorCount += 1;
54
+ await params.history.listenerFilterErrored(subscription, filterResult.reason);
55
+ continue;
56
+ }
57
+
58
+ const previous = effectiveMatchByJobId.get(subscription.jobId);
59
+ if (!previous || shouldReplaceEffectiveMatcher(previous, subscription)) {
60
+ effectiveMatchByJobId.set(subscription.jobId, subscription);
61
+ }
62
+ }
63
+
64
+ let acceptedJobCount = 0;
65
+ let deliveredCount = 0;
66
+ let sawTransientError = false;
67
+ let dispatchErrorCount = 0;
68
+ let firstTransientError: unknown;
69
+
70
+ for (const subscription of effectiveMatchByJobId.values()) {
71
+ const disposition = listenerDisposition(subscription);
72
+ try {
73
+ const result = await deliverEventToListener({
74
+ jobId: subscription.jobId,
75
+ disposition,
76
+ eventRef: params.eventRef,
77
+ deliveryId: params.deliveryId,
78
+ source: params.source,
79
+ event: params.event,
80
+ provider: params.provider,
81
+ payload: params.payload,
82
+ receivedAt: params.receivedAt,
83
+ });
84
+ if (!result.skipped) {
85
+ acceptedJobCount += 1;
86
+ await params.history.listenerTriggered(subscription);
87
+ }
88
+ if (result.buffered) deliveredCount += 1;
89
+ } catch (error) {
90
+ dispatchErrorCount += 1;
91
+ await params.history.listenerDispatchErrored(subscription, toReason(error));
92
+ if (!sawTransientError) {
93
+ sawTransientError = true;
94
+ firstTransientError = error;
95
+ }
96
+ }
97
+ }
98
+
99
+ return {
100
+ engagedCount: filterErrorCount + acceptedJobCount + dispatchErrorCount,
101
+ matchedJobCount: effectiveMatchByJobId.size,
102
+ acceptedJobCount,
103
+ deliveredCount,
104
+ transientErrored: sawTransientError,
105
+ transientError: sawTransientError ? firstTransientError : undefined,
106
+ };
107
+ }
108
+
109
+ interface EvaluateListenerFilterParams {
110
+ subscription: JobListenerSubscription;
111
+ payload: unknown;
112
+ }
113
+
114
+ function evaluateListenerFilter(params: EvaluateListenerFilterParams): StoredFilterEvaluation {
115
+ const filter = params.subscription.config.filter;
116
+ if (filter === null || filter === undefined) return {kind: 'matched'};
117
+ if (typeof filter !== 'string' || filter.trim() === '') {
118
+ return evaluateStoredFilter({
119
+ value: filter,
120
+ context: {event: params.payload},
121
+ invalidReason: 'Listener subscription filter must be a non-empty string when set',
122
+ evaluationFailedReason: 'Listener filter evaluation failed',
123
+ });
124
+ }
125
+
126
+ const snapshot = readFilterSnapshot(params.subscription);
127
+ if (snapshot.kind === 'invalid') return snapshot.result;
128
+
129
+ return evaluateStoredFilter({
130
+ value: filter,
131
+ context: {...snapshot.value, event: params.payload},
132
+ invalidReason: 'Listener subscription filter must be a non-empty string when set',
133
+ evaluationFailedReason: 'Listener filter evaluation failed',
134
+ });
135
+ }
136
+
137
+ function readFilterSnapshot(
138
+ subscription: JobListenerSubscription,
139
+ ):
140
+ | {kind: 'valid'; value: Record<string, unknown>}
141
+ | {kind: 'invalid'; result: Extract<StoredFilterEvaluation, {kind: 'filter-error'}>} {
142
+ const value = subscription.config.filter_snapshot;
143
+ if (value === undefined) return {kind: 'valid', value: {}};
144
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
145
+ return {kind: 'valid', value: value as Record<string, unknown>};
146
+ }
147
+ return {
148
+ kind: 'invalid',
149
+ result: {
150
+ kind: 'filter-error',
151
+ reason: 'Listener filter snapshot must be an object when set',
152
+ },
153
+ };
154
+ }
155
+
156
+ function listenerDisposition(subscription: JobListenerSubscription): JobListenerEventDisposition {
157
+ return subscription.kind === 'until' ? 'resolve' : 'fire';
158
+ }
159
+
160
+ function listenerMatcherSortKey(subscription: JobListenerSubscription): string {
161
+ const kindRank = subscription.kind === 'until' ? '0' : '1';
162
+ return `${kindRank}:${subscription.matcherOrdinal.toString().padStart(10, '0')}`;
163
+ }
164
+
165
+ function shouldReplaceEffectiveMatcher(
166
+ previous: JobListenerSubscription,
167
+ candidate: JobListenerSubscription,
168
+ ): boolean {
169
+ const previousKey = listenerMatcherSortKey(previous);
170
+ const candidateKey = listenerMatcherSortKey(candidate);
171
+ if (candidateKey !== previousKey) return candidateKey < previousKey;
172
+
173
+ logger().warn(
174
+ {
175
+ jobId: candidate.jobId,
176
+ keptSubscriptionId: previous.id,
177
+ candidateSubscriptionId: candidate.id,
178
+ matcherKind: candidate.kind,
179
+ matcherOrdinal: candidate.matcherOrdinal,
180
+ },
181
+ 'duplicate job listener matcher key encountered; choosing deterministic subscription id order',
182
+ );
183
+ return candidate.id < previous.id;
184
+ }