@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,146 @@
1
+ import {eq} from 'drizzle-orm';
2
+ import {db} from '#db/db.js';
3
+ import {triggersDecisions} from '#db/schema/decisions.js';
4
+ import {triggersReceivedEvents} from '#db/schema/received-events.js';
5
+ import {triggerSubscriptionFactory} from '#test/index.js';
6
+ import {TriggerSubscriptionNotCronError} from './errors.js';
7
+
8
+ const runWorkflow = vi.fn();
9
+
10
+ // Keep a real-enough permanent-error class + the classifier so the cron path's
11
+ // permanent/transient branching is exercised without loading the workflows module graph.
12
+ vi.mock('@shipfox/api-workflows', () => {
13
+ class DefinitionNotFoundError extends Error {
14
+ constructor(definitionId: string) {
15
+ super(`Definition not found: ${definitionId}`);
16
+ this.name = 'DefinitionNotFoundError';
17
+ }
18
+ }
19
+ return {
20
+ runWorkflow: (...args: unknown[]) => runWorkflow(...args),
21
+ DefinitionNotFoundError,
22
+ isPermanentRunWorkflowError: (error: unknown) => error instanceof DefinitionNotFoundError,
23
+ };
24
+ });
25
+
26
+ import {DefinitionNotFoundError} from '@shipfox/api-workflows';
27
+
28
+ // Import after mocks so the function under test sees the spy.
29
+ const {fireCronSubscription} = await import('./fire-cron.js');
30
+
31
+ const SLOT = new Date('2026-07-05T02:00:00.000Z');
32
+
33
+ function eventsForWorkspace(workspaceId: string) {
34
+ return db()
35
+ .select()
36
+ .from(triggersReceivedEvents)
37
+ .where(eq(triggersReceivedEvents.workspaceId, workspaceId));
38
+ }
39
+
40
+ function decisionsForEvent(receivedEventId: string) {
41
+ return db()
42
+ .select()
43
+ .from(triggersDecisions)
44
+ .where(eq(triggersDecisions.receivedEventId, receivedEventId));
45
+ }
46
+
47
+ describe('fireCronSubscription', () => {
48
+ beforeEach(() => {
49
+ runWorkflow.mockReset();
50
+ });
51
+
52
+ test('records a routed cron event and a triggered decision on success', async () => {
53
+ const subscription = await triggerSubscriptionFactory.create({
54
+ source: 'cron',
55
+ event: 'tick',
56
+ config: {with: {environment: 'staging'}},
57
+ });
58
+ const run = {id: crypto.randomUUID(), name: 'Cron run'};
59
+ runWorkflow.mockResolvedValue(run);
60
+
61
+ const result = await fireCronSubscription({
62
+ subscriptionId: subscription.id,
63
+ scheduledSlot: SLOT,
64
+ });
65
+
66
+ expect(result).toEqual({outcome: 'fired', run});
67
+ const [payload] = runWorkflow.mock.calls[0] as [Record<string, unknown>];
68
+ expect(payload.triggerPayload).toEqual({
69
+ provider: 'cron',
70
+ source: 'cron',
71
+ event: 'tick',
72
+ scheduleId: subscription.id,
73
+ });
74
+ expect(payload.inputs).toEqual({environment: 'staging'});
75
+ expect(payload.triggerIdempotencyKey).toBe(`${subscription.id}:${SLOT.toISOString()}`);
76
+ const [event] = await eventsForWorkspace(subscription.workspaceId);
77
+ if (!event) throw new Error('received event not found');
78
+ expect(event.origin).toBe('cron');
79
+ expect(event.source).toBe('cron');
80
+ expect(event.provider).toBeNull();
81
+ expect(event.outcome).toBe('routed');
82
+ expect(event.eventRef).toBe(`${subscription.id}:${SLOT.toISOString()}`);
83
+ const decisions = await decisionsForEvent(event.id);
84
+ expect(decisions).toHaveLength(1);
85
+ expect(decisions[0]?.decision).toBe('triggered');
86
+ expect(decisions[0]?.runId).toBe(run.id);
87
+ });
88
+
89
+ test('returns errored (terminal) and records a dispatch-error decision on a permanent failure', async () => {
90
+ const subscription = await triggerSubscriptionFactory.create({
91
+ source: 'cron',
92
+ event: 'tick',
93
+ config: {},
94
+ });
95
+ runWorkflow.mockRejectedValue(new DefinitionNotFoundError('def-gone'));
96
+
97
+ const result = await fireCronSubscription({
98
+ subscriptionId: subscription.id,
99
+ scheduledSlot: SLOT,
100
+ });
101
+
102
+ expect(result).toEqual({outcome: 'errored'});
103
+ const [event] = await eventsForWorkspace(subscription.workspaceId);
104
+ if (!event) throw new Error('received event not found');
105
+ expect(event.outcome).toBe('errored');
106
+ expect(event.processedAt).toBeInstanceOf(Date);
107
+ const decisions = await decisionsForEvent(event.id);
108
+ expect(decisions[0]?.decision).toBe('dispatch-error');
109
+ expect(decisions[0]?.reason).toContain('Definition not found');
110
+ });
111
+
112
+ test('re-throws and leaves the event non-terminal on a transient failure', async () => {
113
+ const subscription = await triggerSubscriptionFactory.create({
114
+ source: 'cron',
115
+ event: 'tick',
116
+ config: {},
117
+ });
118
+ runWorkflow.mockRejectedValue(new Error('cron boom'));
119
+
120
+ await expect(
121
+ fireCronSubscription({subscriptionId: subscription.id, scheduledSlot: SLOT}),
122
+ ).rejects.toThrow('cron boom');
123
+
124
+ const [event] = await eventsForWorkspace(subscription.workspaceId);
125
+ if (!event) throw new Error('received event not found');
126
+ expect(event.outcome).toBe('failed');
127
+ expect(event.processedAt).toBeNull();
128
+ const decisions = await decisionsForEvent(event.id);
129
+ expect(decisions[0]?.decision).toBe('dispatch-error');
130
+ });
131
+
132
+ test('throws and records nothing when the subscription is not a cron trigger', async () => {
133
+ const subscription = await triggerSubscriptionFactory.create({
134
+ source: 'manual',
135
+ event: 'fire',
136
+ config: {},
137
+ });
138
+
139
+ await expect(
140
+ fireCronSubscription({subscriptionId: subscription.id, scheduledSlot: SLOT}),
141
+ ).rejects.toThrow(TriggerSubscriptionNotCronError);
142
+
143
+ expect(await eventsForWorkspace(subscription.workspaceId)).toHaveLength(0);
144
+ expect(runWorkflow).not.toHaveBeenCalled();
145
+ });
146
+ });
@@ -0,0 +1,98 @@
1
+ import {isPermanentRunWorkflowError, runWorkflow, type WorkflowRun} from '@shipfox/api-workflows';
2
+ import {getTriggerSubscriptionById} from '#db/subscriptions.js';
3
+ import {cronFiredCount, cronFireLag} from '#metrics/instance.js';
4
+ import {readConfigInputs} from './config.js';
5
+ import {TriggerSubscriptionNotCronError, TriggerSubscriptionNotFoundError} from './errors.js';
6
+ import {beginTriggerHistory, toReason} from './record-trigger-history.js';
7
+
8
+ export interface FireCronSubscriptionParams {
9
+ subscriptionId: string;
10
+ /**
11
+ * The `next_fire_at` value the schedule held when it was claimed. Anchoring the
12
+ * idempotency key and history `event_ref` on this slot, rather than wall-clock
13
+ * time, makes a retry of the same occurrence a deterministic no-op.
14
+ */
15
+ scheduledSlot: Date;
16
+ }
17
+
18
+ export type FireCronSubscriptionResult =
19
+ | {outcome: 'fired'; run: WorkflowRun}
20
+ | {outcome: 'errored'};
21
+
22
+ /**
23
+ * Fires one cron schedule occurrence. Mirrors `fireManualSubscription`, but is driven
24
+ * by the drain activity rather than an HTTP route, and reports the outcome instead of
25
+ * throwing on a permanent failure so the caller can advance the schedule past a
26
+ * permanently broken definition:
27
+ *
28
+ * - `{outcome: 'fired'}` on success (or an idempotent replay of an already-created run),
29
+ * - `{outcome: 'errored'}` on a permanent `runWorkflow` failure (recorded, schedule advances),
30
+ * - throws on a transient failure so the caller rolls back the advance and retries.
31
+ */
32
+ export async function fireCronSubscription(
33
+ params: FireCronSubscriptionParams,
34
+ ): Promise<FireCronSubscriptionResult> {
35
+ const subscription = await getTriggerSubscriptionById(params.subscriptionId);
36
+ if (!subscription) throw new TriggerSubscriptionNotFoundError(params.subscriptionId);
37
+ if (subscription.source !== 'cron') {
38
+ throw new TriggerSubscriptionNotCronError(params.subscriptionId, subscription.source);
39
+ }
40
+
41
+ // Deterministic per-occurrence identity: unique across (subscription, slot), stable
42
+ // across retries. Used for both the run idempotency key and the received-event ref.
43
+ const eventRef = `${subscription.id}:${params.scheduledSlot.toISOString()}`;
44
+ const historyBase = {
45
+ origin: 'cron' as const,
46
+ workspaceId: subscription.workspaceId,
47
+ provider: null,
48
+ source: subscription.source,
49
+ event: subscription.event,
50
+ deliveryId: null,
51
+ connectionId: null,
52
+ connectionName: null,
53
+ payload: null,
54
+ // The scheduled occurrence is the logical arrival time of the tick event.
55
+ receivedAt: params.scheduledSlot,
56
+ eventRef,
57
+ };
58
+
59
+ let run: WorkflowRun;
60
+ try {
61
+ run = await runWorkflow({
62
+ workspaceId: subscription.workspaceId,
63
+ projectId: subscription.projectId,
64
+ definitionId: subscription.workflowDefinitionId,
65
+ triggerPayload: {
66
+ provider: 'cron',
67
+ source: 'cron',
68
+ event: 'tick',
69
+ scheduleId: subscription.id,
70
+ },
71
+ inputs: readConfigInputs(subscription),
72
+ triggerIdempotencyKey: eventRef,
73
+ });
74
+ } catch (error) {
75
+ const failure = await beginTriggerHistory(historyBase);
76
+ await failure.dispatchErrored(subscription, toReason(error));
77
+ if (isPermanentRunWorkflowError(error)) {
78
+ recordFire('errored', params.scheduledSlot);
79
+ await failure.allErrored(1);
80
+ return {outcome: 'errored'};
81
+ }
82
+ // Transient: leave the event non-terminal and re-throw so the caller rolls back
83
+ // the advance and the schedule stays due for the next tick.
84
+ await failure.failed(1);
85
+ throw error;
86
+ }
87
+
88
+ const history = await beginTriggerHistory(historyBase);
89
+ await history.triggered(subscription, run);
90
+ recordFire('fired', params.scheduledSlot);
91
+ await history.routed(1);
92
+ return {outcome: 'fired', run};
93
+ }
94
+
95
+ function recordFire(outcome: 'fired' | 'errored', scheduledSlot: Date): void {
96
+ cronFiredCount.add(1, {outcome});
97
+ cronFireLag.record(Math.max(0, Date.now() - scheduledSlot.getTime()));
98
+ }
@@ -0,0 +1,157 @@
1
+ import {eq} from 'drizzle-orm';
2
+ import {db} from '#db/db.js';
3
+ import {triggersDecisions} from '#db/schema/decisions.js';
4
+ import {triggersReceivedEvents} from '#db/schema/received-events.js';
5
+ import {triggerSubscriptionFactory} from '#test/index.js';
6
+ import {TriggerSubscriptionNotManualError} from './errors.js';
7
+
8
+ const runWorkflow = vi.fn();
9
+
10
+ // Keep real-enough permanent-error classes + the classifier so the manual path's
11
+ // permanent/transient branching is exercised without loading the workflows module graph.
12
+ vi.mock('@shipfox/api-workflows', () => {
13
+ class DefinitionNotFoundError extends Error {
14
+ constructor(definitionId: string) {
15
+ super(`Definition not found: ${definitionId}`);
16
+ this.name = 'DefinitionNotFoundError';
17
+ }
18
+ }
19
+ return {
20
+ runWorkflow: (...args: unknown[]) => runWorkflow(...args),
21
+ DefinitionNotFoundError,
22
+ isPermanentRunWorkflowError: (error: unknown) => error instanceof DefinitionNotFoundError,
23
+ };
24
+ });
25
+
26
+ import {DefinitionNotFoundError} from '@shipfox/api-workflows';
27
+
28
+ // Import after mocks so the function under test sees the spy.
29
+ const {fireManualSubscription} = await import('./fire-manual.js');
30
+
31
+ function eventsForWorkspace(workspaceId: string) {
32
+ return db()
33
+ .select()
34
+ .from(triggersReceivedEvents)
35
+ .where(eq(triggersReceivedEvents.workspaceId, workspaceId));
36
+ }
37
+
38
+ function decisionsForEvent(receivedEventId: string) {
39
+ return db()
40
+ .select()
41
+ .from(triggersDecisions)
42
+ .where(eq(triggersDecisions.receivedEventId, receivedEventId));
43
+ }
44
+
45
+ describe('fireManualSubscription (trigger history)', () => {
46
+ beforeEach(() => {
47
+ runWorkflow.mockReset();
48
+ });
49
+
50
+ test('records a routed manual event and a triggered decision on success', async () => {
51
+ const subscription = await triggerSubscriptionFactory.create({
52
+ source: 'manual',
53
+ event: 'fire',
54
+ config: {},
55
+ });
56
+ const run = {id: crypto.randomUUID(), name: 'Manual run'};
57
+ runWorkflow.mockResolvedValue(run);
58
+
59
+ const result = await fireManualSubscription({
60
+ subscriptionId: subscription.id,
61
+ callerWorkspaceId: subscription.workspaceId,
62
+ userId: crypto.randomUUID(),
63
+ });
64
+
65
+ expect(result).toEqual(run);
66
+ const [event] = await db()
67
+ .select()
68
+ .from(triggersReceivedEvents)
69
+ .where(eq(triggersReceivedEvents.eventRef, run.id));
70
+ if (!event) throw new Error('received event not found');
71
+ expect(event.origin).toBe('manual');
72
+ expect(event.outcome).toBe('routed');
73
+ expect(event.matchedCount).toBe(1);
74
+ const decisions = await decisionsForEvent(event.id);
75
+ expect(decisions).toHaveLength(1);
76
+ expect(decisions[0]?.decision).toBe('triggered');
77
+ expect(decisions[0]?.runId).toBe(run.id);
78
+ expect(decisions[0]?.runName).toBe('Manual run');
79
+ });
80
+
81
+ test('records a failed manual event with a dispatch-error decision and re-throws when runWorkflow throws', async () => {
82
+ const subscription = await triggerSubscriptionFactory.create({
83
+ source: 'manual',
84
+ event: 'fire',
85
+ config: {},
86
+ });
87
+ runWorkflow.mockRejectedValue(new Error('manual boom'));
88
+
89
+ await expect(
90
+ fireManualSubscription({
91
+ subscriptionId: subscription.id,
92
+ callerWorkspaceId: subscription.workspaceId,
93
+ userId: crypto.randomUUID(),
94
+ }),
95
+ ).rejects.toThrow('manual boom');
96
+
97
+ const events = await eventsForWorkspace(subscription.workspaceId);
98
+ expect(events).toHaveLength(1);
99
+ const event = events[0];
100
+ if (!event) throw new Error('received event not found');
101
+ expect(event.origin).toBe('manual');
102
+ expect(event.outcome).toBe('failed');
103
+ expect(event.matchedCount).toBe(1);
104
+ expect(event.processedAt).toBeNull();
105
+ const decisions = await decisionsForEvent(event.id);
106
+ expect(decisions).toHaveLength(1);
107
+ expect(decisions[0]?.decision).toBe('dispatch-error');
108
+ expect(decisions[0]?.reason).toContain('manual boom');
109
+ });
110
+
111
+ test('records an errored (terminal) manual event when runWorkflow fails permanently', async () => {
112
+ const subscription = await triggerSubscriptionFactory.create({
113
+ source: 'manual',
114
+ event: 'fire',
115
+ config: {},
116
+ });
117
+ runWorkflow.mockRejectedValue(new DefinitionNotFoundError('def-gone'));
118
+
119
+ await expect(
120
+ fireManualSubscription({
121
+ subscriptionId: subscription.id,
122
+ callerWorkspaceId: subscription.workspaceId,
123
+ userId: crypto.randomUUID(),
124
+ }),
125
+ ).rejects.toThrow('Definition not found');
126
+
127
+ const events = await eventsForWorkspace(subscription.workspaceId);
128
+ expect(events).toHaveLength(1);
129
+ const event = events[0];
130
+ if (!event) throw new Error('received event not found');
131
+ expect(event.outcome).toBe('errored');
132
+ expect(event.matchedCount).toBe(1);
133
+ expect(event.processedAt).toBeInstanceOf(Date);
134
+ const decisions = await decisionsForEvent(event.id);
135
+ expect(decisions[0]?.decision).toBe('dispatch-error');
136
+ expect(decisions[0]?.reason).toContain('Definition not found');
137
+ });
138
+
139
+ test('does not record a received event when the subscription is not a manual trigger', async () => {
140
+ const subscription = await triggerSubscriptionFactory.create({
141
+ source: 'github',
142
+ event: 'push',
143
+ config: {},
144
+ });
145
+
146
+ await expect(
147
+ fireManualSubscription({
148
+ subscriptionId: subscription.id,
149
+ callerWorkspaceId: subscription.workspaceId,
150
+ userId: crypto.randomUUID(),
151
+ }),
152
+ ).rejects.toThrow(TriggerSubscriptionNotManualError);
153
+
154
+ expect(await eventsForWorkspace(subscription.workspaceId)).toHaveLength(0);
155
+ expect(runWorkflow).not.toHaveBeenCalled();
156
+ });
157
+ });
@@ -0,0 +1,92 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {isPermanentRunWorkflowError, runWorkflow, type WorkflowRun} from '@shipfox/api-workflows';
3
+ import {getTriggerSubscriptionById} from '#db/subscriptions.js';
4
+ import {
5
+ eventOutcomeCount,
6
+ eventReceivedCount,
7
+ subscriptionTriggeredCount,
8
+ } from '#metrics/instance.js';
9
+ import {readConfigInputs} from './config.js';
10
+ import {
11
+ TriggerSubscriptionNotFoundError,
12
+ TriggerSubscriptionNotManualError,
13
+ TriggerWorkspaceMismatchError,
14
+ } from './errors.js';
15
+ import {beginTriggerHistory, toReason} from './record-trigger-history.js';
16
+
17
+ export interface FireManualSubscriptionParams {
18
+ subscriptionId: string;
19
+ callerWorkspaceId: string;
20
+ userId: string;
21
+ inputs?: Record<string, unknown> | undefined;
22
+ }
23
+
24
+ export async function fireManualSubscription(
25
+ params: FireManualSubscriptionParams,
26
+ ): Promise<WorkflowRun> {
27
+ const subscription = await getTriggerSubscriptionById(params.subscriptionId);
28
+ if (!subscription) throw new TriggerSubscriptionNotFoundError(params.subscriptionId);
29
+ if (subscription.source !== 'manual') {
30
+ throw new TriggerSubscriptionNotManualError(params.subscriptionId, subscription.source);
31
+ }
32
+ // Defence in depth: unreachable from the HTTP route, but required for any direct caller.
33
+ if (subscription.workspaceId !== params.callerWorkspaceId) {
34
+ throw new TriggerWorkspaceMismatchError(
35
+ params.subscriptionId,
36
+ subscription.workspaceId,
37
+ params.callerWorkspaceId,
38
+ );
39
+ }
40
+
41
+ // Manual fires have no upstream event id. Use the run id after success; failed
42
+ // attempts need a synthesized ref because there is no run to key on.
43
+ const historyBase = {
44
+ origin: 'manual' as const,
45
+ workspaceId: subscription.workspaceId,
46
+ provider: null,
47
+ source: subscription.source,
48
+ event: subscription.event,
49
+ deliveryId: null,
50
+ connectionId: null,
51
+ connectionName: null,
52
+ payload: null,
53
+ receivedAt: new Date(),
54
+ };
55
+
56
+ eventReceivedCount.add(1, {provider: 'manual'});
57
+
58
+ let run: WorkflowRun;
59
+ try {
60
+ run = await runWorkflow({
61
+ workspaceId: subscription.workspaceId,
62
+ projectId: subscription.projectId,
63
+ definitionId: subscription.workflowDefinitionId,
64
+ triggerPayload: {
65
+ provider: 'manual',
66
+ source: 'manual',
67
+ event: 'fire',
68
+ subscriptionId: subscription.id,
69
+ userId: params.userId,
70
+ },
71
+ inputs: params.inputs ?? readConfigInputs(subscription),
72
+ });
73
+ } catch (error) {
74
+ const failure = await beginTriggerHistory({...historyBase, eventRef: randomUUID()});
75
+ await failure.dispatchErrored(subscription, toReason(error));
76
+ if (isPermanentRunWorkflowError(error)) {
77
+ eventOutcomeCount.add(1, {provider: 'manual', outcome: 'errored'});
78
+ await failure.allErrored(1);
79
+ } else {
80
+ eventOutcomeCount.add(1, {provider: 'manual', outcome: 'failed'});
81
+ await failure.failed(1);
82
+ }
83
+ throw error;
84
+ }
85
+
86
+ const history = await beginTriggerHistory({...historyBase, eventRef: run.id});
87
+ await history.triggered(subscription, run);
88
+ subscriptionTriggeredCount.add(1, {provider: 'manual'});
89
+ eventOutcomeCount.add(1, {provider: 'manual', outcome: 'routed'});
90
+ await history.routed(1);
91
+ return run;
92
+ }
@@ -0,0 +1,34 @@
1
+ export {
2
+ type ComputeNextFireAtParams,
3
+ computeNextFireAt,
4
+ } from './compute-next-fire-at.js';
5
+ export {readConfigInputs} from './config.js';
6
+ export {
7
+ type DispatchIntegrationEventParams,
8
+ dispatchIntegrationEvent,
9
+ } from './dispatch-integration-event.js';
10
+ export {
11
+ type CronDrainSummary,
12
+ type DrainDueCronSchedulesParams,
13
+ drainDueCronSchedules,
14
+ } from './drain-cron-schedules.js';
15
+ export type {CronSchedule} from './entities/cron-schedule.js';
16
+ export type {TriggerSubscription} from './entities/subscription.js';
17
+ export {
18
+ ManualTriggerNotFoundError,
19
+ TriggerSubscriptionNotCronError,
20
+ TriggerSubscriptionNotFoundError,
21
+ TriggerSubscriptionNotManualError,
22
+ TriggerWorkspaceMismatchError,
23
+ } from './errors.js';
24
+ export {
25
+ type FireCronSubscriptionParams,
26
+ type FireCronSubscriptionResult,
27
+ fireCronSubscription,
28
+ } from './fire-cron.js';
29
+ export {type FireManualSubscriptionParams, fireManualSubscription} from './fire-manual.js';
30
+ export {
31
+ type RouteEventToJobListenersParams,
32
+ type RouteEventToJobListenersResult,
33
+ routeEventToJobListeners,
34
+ } from './route-event-to-job-listeners.js';
@@ -0,0 +1,159 @@
1
+ import {jobListenerSubscriptionFactory, triggerSubscriptionFactory} from '#test/index.js';
2
+
3
+ const runWorkflow = vi.fn();
4
+ const insertReceivedEvent = vi.fn();
5
+ const markReceivedEventRouted = vi.fn();
6
+ const upsertTriggeredDecision = vi.fn();
7
+
8
+ vi.mock('@shipfox/api-workflows', () => ({
9
+ runWorkflow: (...args: unknown[]) => runWorkflow(...args),
10
+ isPermanentRunWorkflowError: () => false,
11
+ }));
12
+
13
+ vi.mock('#db/event-history.js', () => ({
14
+ insertReceivedEvent: (...args: unknown[]) => insertReceivedEvent(...args),
15
+ markReceivedEventDiscarded: vi.fn(),
16
+ markReceivedEventRouted: (...args: unknown[]) => markReceivedEventRouted(...args),
17
+ markReceivedEventFailed: vi.fn(),
18
+ markReceivedEventErrored: vi.fn(),
19
+ upsertTriggeredDecision: (...args: unknown[]) => upsertTriggeredDecision(...args),
20
+ upsertDispatchErrorDecision: vi.fn(),
21
+ upsertFilterErrorDecision: vi.fn(),
22
+ upsertListenerTriggeredDecision: vi.fn(),
23
+ upsertListenerDispatchErrorDecision: vi.fn(),
24
+ upsertListenerFilterErrorDecision: vi.fn(),
25
+ }));
26
+
27
+ // Import after mocks so the code under test sees the spies.
28
+ const {beginTriggerHistory, toReason} = await import('./record-trigger-history.js');
29
+ const {fireManualSubscription} = await import('./fire-manual.js');
30
+
31
+ describe('trigger history is best-effort and never blocks triggering', () => {
32
+ beforeEach(() => {
33
+ runWorkflow.mockReset();
34
+ insertReceivedEvent.mockReset();
35
+ insertReceivedEvent.mockRejectedValue(new Error('history db down'));
36
+ });
37
+
38
+ test('beginTriggerHistory resolves and its methods never throw when the insert fails', async () => {
39
+ const recorder = await beginTriggerHistory({
40
+ eventRef: crypto.randomUUID(),
41
+ origin: 'integration',
42
+ workspaceId: crypto.randomUUID(),
43
+ provider: 'github',
44
+ source: 'github',
45
+ event: 'push',
46
+ deliveryId: null,
47
+ connectionId: null,
48
+ connectionName: null,
49
+ payload: null,
50
+ receivedAt: new Date(),
51
+ });
52
+ const subscription = triggerSubscriptionFactory.build();
53
+ const listenerSubscription = jobListenerSubscriptionFactory.build();
54
+
55
+ await expect(
56
+ recorder.triggered(subscription, {id: crypto.randomUUID(), name: 'r'}),
57
+ ).resolves.toBeUndefined();
58
+ await expect(recorder.dispatchErrored(subscription, 'boom')).resolves.toBeUndefined();
59
+ await expect(recorder.filterErrored(subscription, 'bad filter')).resolves.toBeUndefined();
60
+ await expect(recorder.listenerTriggered(listenerSubscription)).resolves.toBeUndefined();
61
+ await expect(
62
+ recorder.listenerDispatchErrored(listenerSubscription, 'boom'),
63
+ ).resolves.toBeUndefined();
64
+ await expect(
65
+ recorder.listenerFilterErrored(listenerSubscription, 'bad filter'),
66
+ ).resolves.toBeUndefined();
67
+ await expect(recorder.discarded()).resolves.toBeUndefined();
68
+ await expect(recorder.routed(1)).resolves.toBeUndefined();
69
+ await expect(recorder.failed(1)).resolves.toBeUndefined();
70
+ await expect(recorder.allErrored(1)).resolves.toBeUndefined();
71
+ });
72
+
73
+ test('fireManualSubscription still returns the run when history recording fails', async () => {
74
+ const run = {id: crypto.randomUUID(), name: 'Manual run'};
75
+ runWorkflow.mockResolvedValue(run);
76
+ const subscription = await triggerSubscriptionFactory.create({
77
+ source: 'manual',
78
+ event: 'fire',
79
+ config: {},
80
+ });
81
+
82
+ const result = await fireManualSubscription({
83
+ subscriptionId: subscription.id,
84
+ callerWorkspaceId: subscription.workspaceId,
85
+ userId: crypto.randomUUID(),
86
+ });
87
+
88
+ expect(result).toEqual(run);
89
+ expect(runWorkflow).toHaveBeenCalledTimes(1);
90
+ });
91
+ });
92
+
93
+ describe('a per-write failure after a successful insert is swallowed', () => {
94
+ beforeEach(() => {
95
+ insertReceivedEvent.mockReset();
96
+ markReceivedEventRouted.mockReset();
97
+ upsertTriggeredDecision.mockReset();
98
+ insertReceivedEvent.mockResolvedValue(crypto.randomUUID());
99
+ markReceivedEventRouted.mockRejectedValue(new Error('route write failed'));
100
+ upsertTriggeredDecision.mockRejectedValue(new Error('decision write failed'));
101
+ });
102
+
103
+ test('recorder methods resolve when the insert succeeds but a later write throws', async () => {
104
+ const recorder = await beginTriggerHistory({
105
+ eventRef: crypto.randomUUID(),
106
+ origin: 'integration',
107
+ workspaceId: crypto.randomUUID(),
108
+ provider: 'github',
109
+ source: 'github',
110
+ event: 'push',
111
+ deliveryId: null,
112
+ connectionId: null,
113
+ connectionName: null,
114
+ payload: null,
115
+ receivedAt: new Date(),
116
+ });
117
+ const subscription = triggerSubscriptionFactory.build();
118
+
119
+ await expect(
120
+ recorder.triggered(subscription, {id: crypto.randomUUID(), name: 'r'}),
121
+ ).resolves.toBeUndefined();
122
+ await expect(recorder.routed(1)).resolves.toBeUndefined();
123
+
124
+ // Prove the post-insert writes were actually attempted (not skipped by the
125
+ // missing-id no-op), so the assertions exercise the swallow path they claim to.
126
+ expect(upsertTriggeredDecision).toHaveBeenCalledTimes(1);
127
+ expect(markReceivedEventRouted).toHaveBeenCalledTimes(1);
128
+ });
129
+ });
130
+
131
+ describe('toReason', () => {
132
+ test('caps an over-long message at the maximum reason length', () => {
133
+ const reason = toReason(new Error('x'.repeat(5000)));
134
+
135
+ expect(reason).toHaveLength(2000);
136
+ });
137
+
138
+ test('stringifies a non-Error throwable', () => {
139
+ const reason = toReason('plain string failure');
140
+
141
+ expect(reason).toBe('plain string failure');
142
+ });
143
+
144
+ test('falls back when a non-Error throwable cannot be stringified', () => {
145
+ const reason = toReason({
146
+ toString() {
147
+ throw new Error('string conversion failed');
148
+ },
149
+ });
150
+
151
+ expect(reason).toBe('[unprintable thrown value]');
152
+ });
153
+
154
+ test('passes a short Error message through verbatim', () => {
155
+ const reason = toReason(new Error('definition deleted'));
156
+
157
+ expect(reason).toBe('definition deleted');
158
+ });
159
+ });