Implement runOnce and runOnceLocalOnly functions
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Added runOnce and runOnceLocalOnly to support running idempotent actions
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
title: "Run Once"
|
||||
sidebarTitle: "Run Once"
|
||||
description: "Perform an action only once per run, even if your server is restarted."
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
A `runOnce` function is available to use inside a `Trigger.run` function through the `context` argument, allowing you to run a callback function only once [\*](#caveats) per run, based on the key you provide. This makes it so you can write code that participates in the Trigger.dev resumability system, even if your server is restarted.
|
||||
|
||||
<Note>
|
||||
Please see our detailed [Keys and Resumability guide](/guides/resumability)
|
||||
for more information on how Trigger.dev handles resumability.
|
||||
</Note>
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
id: "run-once-example",
|
||||
name: "Run Once Example",
|
||||
on: customEvent({
|
||||
name: "example.runOnce",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
await ctx.runOnce("Example Key", async (idempotencyKey) =>
|
||||
performIdempotentAction(idempotencyKey)
|
||||
);
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
The `performIdempotentAction` above is only called once [\*](#caveats) per run, even if the server is restarted. The `idempotencyKey` is a unique key that is generated for each run, and is used to ensure that the action is only performed once per run.
|
||||
|
||||
<Info>
|
||||
The Stripe API is a great example of an API that uses idempotency keys to
|
||||
ensure that an action is only performed once. You can read more about it in
|
||||
their [Idempotent Requests
|
||||
docs](https://stripe.com/docs/api/idempotent_requests)
|
||||
</Info>
|
||||
|
||||
Your callback can also optionally return a value, and that value will be returned from the `runOnce` function.
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
id: "run-once-example",
|
||||
name: "Run Once Example",
|
||||
on: customEvent({
|
||||
name: "example.runOnce",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const output = await ctx.runOnce("Example Key", async (idempotencyKey) =>
|
||||
performIdempotentAction(idempotencyKey)
|
||||
);
|
||||
|
||||
// Do something with the output
|
||||
// ...
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
In your app.trigger.dev run dashboard we will also display the output of your `runOnce` function, so you can see what it returned easily:
|
||||
|
||||

|
||||
|
||||
## Caveats
|
||||
|
||||
As you may have noticed above, we've used cleverly placed asterisks above to signal that your callback passed to the `runOnce` function MAY actually be called more than once, in certain situations.
|
||||
|
||||
It's possible that in between us running the `runOnce` callback and attempting to send the result to trigger.dev, an issue occurs and we are unable to save the result. If this happens, and we [resume](/guides/resumability) the same run at a later time, your callback function will be called again (but with the same idempotency key).
|
||||
|
||||
This is why it's important that your callback function is truly idempotent. If you're not sure what that means, we recommend reading the [Stripe docs on idempotent requests](https://stripe.com/docs/api/idempotent_requests).
|
||||
|
||||
## Local Only
|
||||
|
||||
If you don't want to send the result of the `runOnce` callback to trigger.dev, you can alternatively use the `runOnceLocalOnly` function. This is useful if you want to use the `runOnce` functionality, but don't want to store the value in the Trigger.dev database.
|
||||
|
||||
<Warning>
|
||||
`runOnceLocalOnly` will ALWAYS call your callback function, so be extra sure
|
||||
your callback is truly idempotent. See below for more information.
|
||||
</Warning>
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
id: "run-once-local-only-example",
|
||||
name: "Run Once Local Only Example",
|
||||
on: customEvent({
|
||||
name: "example.runOnce",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const output = await ctx.runOnceLocalOnly(
|
||||
"Example Key",
|
||||
async (idempotencyKey) => performIdempotentAction(idempotencyKey)
|
||||
);
|
||||
|
||||
// Do something with the output
|
||||
// ...
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
## Real World Example
|
||||
|
||||
Let's say you want to use the Stripe SDK to make an idempotent charge to a customer using trigger.dev. You can use the `runOnce` function to generate an `idempotencyKey` and pass it to the Stripe SDK, ensuring that the charge is only made once per run.
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import Stripe from "stripe";
|
||||
import z from "zod";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
|
||||
new Trigger({
|
||||
id: "stripe-charge",
|
||||
name: "Stripe Charge",
|
||||
on: customEvent({
|
||||
name: "example.stripeCharge",
|
||||
schema: z.object({
|
||||
orderId: z.string(),
|
||||
customerId: z.string(),
|
||||
amount: z.number(),
|
||||
}),
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const { customerId, amount, orderId } = event.data;
|
||||
|
||||
const stripeResponse = await ctx.runOnce(
|
||||
orderId,
|
||||
async (idempotencyKey) => {
|
||||
return await stripe.charges.create(
|
||||
{
|
||||
amount,
|
||||
currency: "usd",
|
||||
customer: customerId,
|
||||
},
|
||||
{
|
||||
idempotencyKey,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return stripeResponse;
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
+22
-9
@@ -49,7 +49,12 @@
|
||||
"navigation": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": ["welcome", "getting-started", "get-help", "viewing-runs"]
|
||||
"pages": [
|
||||
"welcome",
|
||||
"getting-started",
|
||||
"get-help",
|
||||
"viewing-runs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Example workflows",
|
||||
@@ -95,11 +100,15 @@
|
||||
},
|
||||
{
|
||||
"group": "Slack",
|
||||
"pages": ["integrations/apis/slack/actions/post-message"]
|
||||
"pages": [
|
||||
"integrations/apis/slack/actions/post-message"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Resend.com",
|
||||
"pages": ["integrations/apis/resend/actions/send-email"]
|
||||
"pages": [
|
||||
"integrations/apis/resend/actions/send-email"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "WhatsApp",
|
||||
@@ -107,7 +116,9 @@
|
||||
"integrations/apis/whatsapp/index",
|
||||
{
|
||||
"group": "Events",
|
||||
"pages": ["integrations/apis/whatsapp/events/message"]
|
||||
"pages": [
|
||||
"integrations/apis/whatsapp/events/message"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Actions",
|
||||
@@ -137,6 +148,7 @@
|
||||
"functions/logging",
|
||||
"functions/delays",
|
||||
"functions/send-event",
|
||||
"functions/run-once",
|
||||
"functions/loops-conditionals-etc"
|
||||
]
|
||||
},
|
||||
@@ -161,7 +173,9 @@
|
||||
},
|
||||
{
|
||||
"group": "API Reference",
|
||||
"pages": ["api/events/sendEvent"]
|
||||
"pages": [
|
||||
"api/events/sendEvent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Webhook Catalog",
|
||||
@@ -193,8 +207,7 @@
|
||||
},
|
||||
"analytics": {
|
||||
"posthog": {
|
||||
"apiKey": "phc_cU8Ow5tDcy9alp09yWYpad98CHafOsfsXK3HzLM6uy0"
|
||||
"apiKey": "phc_cU8Ow5tDcy9alp09yWYpad98CHafOsfsXK3HzLM6uy0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,13 @@ async function parseStep(
|
||||
status: original.status,
|
||||
};
|
||||
switch (original.type) {
|
||||
case "RUN_ONCE":
|
||||
return {
|
||||
...base,
|
||||
type: "RUN_ONCE" as const,
|
||||
output: original.output,
|
||||
idempotencyKey: original.id,
|
||||
};
|
||||
case "LOG_MESSAGE":
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -64,3 +64,23 @@ export async function createStepOnce(
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findWorkflowStepById(stepId: string) {
|
||||
return prisma.workflowRunStep.findUnique({
|
||||
where: {
|
||||
id: stepId,
|
||||
},
|
||||
include: {
|
||||
run: {
|
||||
include: {
|
||||
workflow: true,
|
||||
environment: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+75
-49
@@ -6,6 +6,7 @@ import {
|
||||
ChevronUpIcon,
|
||||
ClockIcon,
|
||||
GlobeAltIcon,
|
||||
KeyIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ArrowPathRoundedSquareIcon,
|
||||
@@ -82,15 +83,15 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex sticky -top-12 py-4 -mt-4 -ml-1 pl-1 bg-slate-850 justify-between items-center z-10">
|
||||
<div className="sticky -top-12 z-10 -mt-4 -ml-1 flex items-center justify-between bg-slate-850 py-4 pl-1">
|
||||
<Header1 className="truncate text-slate-300">Run {run.id}</Header1>
|
||||
<div className="flex gap-2">
|
||||
{run.isTest && (
|
||||
<Body
|
||||
size="extra-small"
|
||||
className="flex items-center pl-2 pr-3 py-0.5 rounded uppercase whitespace-nowrap tracking-wide text-slate-500"
|
||||
className="flex items-center whitespace-nowrap rounded py-0.5 pl-2 pr-3 uppercase tracking-wide text-slate-500"
|
||||
>
|
||||
<BeakerIcon className="h-4 w-4 mr-1" />
|
||||
<BeakerIcon className="mr-1 h-4 w-4" />
|
||||
Test Run
|
||||
</Body>
|
||||
)}
|
||||
@@ -122,28 +123,28 @@ export default function Page() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowPathRoundedSquareIcon className="h-5 w-5 -ml-1" />
|
||||
<ArrowPathRoundedSquareIcon className="-ml-1 h-5 w-5" />
|
||||
Rerun
|
||||
</PrimaryButton>
|
||||
</rerunFetcher.Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex gap-6 ml-[-3px] flex-wrap">
|
||||
<li className="flex gap-2 items-center">
|
||||
<ul className="ml-[-3px] flex flex-wrap gap-6">
|
||||
<li className="flex items-center gap-2">
|
||||
{runStatusIcon(run.status, "large")}
|
||||
<Header2 size="small" className="text-slate-400">
|
||||
{runStatusLabel(run.status)}
|
||||
</Header2>
|
||||
</li>
|
||||
<li className="flex gap-1 items-center">
|
||||
<li className="flex items-center gap-1">
|
||||
<Header2 size="small" className="text-slate-400">
|
||||
{run.startedAt &&
|
||||
`Started: ${formatDateTime(run.startedAt, "long")}`}
|
||||
</Header2>
|
||||
</li>
|
||||
{run.duration && (
|
||||
<li className="flex gap-1 items-center">
|
||||
<li className="flex items-center gap-1">
|
||||
<Header2 size="small" className="text-slate-400">
|
||||
Duration: {humanizeDuration(run.duration)}
|
||||
</Header2>
|
||||
@@ -161,7 +162,7 @@ export default function Page() {
|
||||
|
||||
{run.status === "SUCCESS" && (
|
||||
<>
|
||||
<div className="h-3 w-full ml-[10px] -mr-[10px] border-l border-slate-700"></div>
|
||||
<div className="ml-[10px] -mr-[10px] h-3 w-full border-l border-slate-700"></div>
|
||||
<Panel>
|
||||
<PanelHeader
|
||||
icon={<CheckCircleIcon className="h-6 w-6 text-green-500" />}
|
||||
@@ -217,12 +218,12 @@ export default function Page() {
|
||||
)}
|
||||
|
||||
{run.status === "RUNNING" && (
|
||||
<div className="h-10 w-full ml-[10px] border-dashed border-l border-gradient border-slate-700"></div>
|
||||
<div className="border-gradient ml-[10px] h-10 w-full border-l border-dashed border-slate-700"></div>
|
||||
)}
|
||||
|
||||
{run.status === "TIMED_OUT" && (
|
||||
<>
|
||||
<div className="h-3 w-full ml-[10px] -mr-[10px] border-l border-slate-700"></div>
|
||||
<div className="ml-[10px] -mr-[10px] h-3 w-full border-l border-slate-700"></div>
|
||||
<Panel>
|
||||
<PanelHeader
|
||||
icon={
|
||||
@@ -262,7 +263,7 @@ function TriggerStep({ trigger }: { trigger: Trigger }) {
|
||||
<Panel className="mt-4">
|
||||
<PanelHeader
|
||||
icon={
|
||||
<div className="h-6 w-6 mr-1">
|
||||
<div className="mr-1 h-6 w-6">
|
||||
<TriggerTypeIcon
|
||||
type={trigger.type}
|
||||
provider={trigger.integration}
|
||||
@@ -285,7 +286,7 @@ function TriggerStep({ trigger }: { trigger: Trigger }) {
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
<div className="h-3 w-full ml-[10px] -mr-[10px] border-l border-slate-700"></div>
|
||||
<div className="ml-[10px] -mr-[10px] h-3 w-full border-l border-slate-700"></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -298,16 +299,16 @@ function WorkflowStep({ step }: { step: Step }) {
|
||||
switch (step.type) {
|
||||
case "DISCONNECTION":
|
||||
return (
|
||||
<div className="flex items-stretch w-full">
|
||||
<div className="relative flex w-5 border-l border-dashed border-slate-700 ml-2.5">
|
||||
<div className="absolute top-2 -left-[18px] p-1 bg-slate-850 rounded-full">
|
||||
<div className="flex w-full items-stretch">
|
||||
<div className="relative ml-2.5 flex w-5 border-l border-dashed border-slate-700">
|
||||
<div className="absolute top-2 -left-[18px] rounded-full bg-slate-850 p-1">
|
||||
{runStatusIcon("DISCONNECTED", "large")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Body
|
||||
size="small"
|
||||
className={classNames("font-mono my-4 ml-0.5 text-slate-400")}
|
||||
className={classNames("my-4 ml-0.5 font-mono text-slate-400")}
|
||||
>
|
||||
{step.startedAt && step.finishedAt
|
||||
? `The run disconnected for ${humanizeDuration(
|
||||
@@ -325,16 +326,16 @@ function WorkflowStep({ step }: { step: Step }) {
|
||||
);
|
||||
case "LOG_MESSAGE":
|
||||
return (
|
||||
<div className="flex items-stretch w-full">
|
||||
<div className="relative flex shrink-0 w-5 border-l border-slate-700 ml-2.5">
|
||||
<div className="absolute top-2 -left-[18px] p-1 bg-slate-850 rounded-full">
|
||||
<div className="flex w-full items-stretch">
|
||||
<div className="relative ml-2.5 flex w-5 shrink-0 border-l border-slate-700">
|
||||
<div className="absolute top-2 -left-[18px] rounded-full bg-slate-850 p-1">
|
||||
<ChatBubbleOvalLeftEllipsisIcon
|
||||
className={classNames("h-7 w-7", logColor[step.input.level])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full my-4">
|
||||
<div className={classNames("flex gap-2 items-center")}>
|
||||
<div className="my-4 flex w-full flex-col gap-2">
|
||||
<div className={classNames("flex items-center gap-2")}>
|
||||
<Body
|
||||
size="small"
|
||||
className={classNames(
|
||||
@@ -349,14 +350,14 @@ function WorkflowStep({ step }: { step: Step }) {
|
||||
Object.keys(step.input.properties).length !== 0 && (
|
||||
<button
|
||||
onClick={toggleCodeBlock}
|
||||
className="text-sm text-slate-400 hover:text-slate-200 transition"
|
||||
className="text-sm text-slate-400 transition hover:text-slate-200"
|
||||
>
|
||||
{showCodeBlock ? (
|
||||
<span className="flex gap-1 items-center">
|
||||
<span className="flex items-center gap-1">
|
||||
Hide custom fields <ChevronUpIcon className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex gap-1 items-center">
|
||||
<span className="flex items-center gap-1">
|
||||
View custom fields{" "}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</span>
|
||||
@@ -379,9 +380,9 @@ function WorkflowStep({ step }: { step: Step }) {
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex items-stretch w-full">
|
||||
<div className="relative flex shrink-0 w-5 border-l border-slate-700 ml-2.5">
|
||||
<div className="absolute top-[23px] -left-[18px] p-1 bg-slate-850 rounded-full">
|
||||
<div className="flex w-full items-stretch">
|
||||
<div className="relative ml-2.5 flex w-5 shrink-0 border-l border-slate-700">
|
||||
<div className="absolute top-[23px] -left-[18px] rounded-full bg-slate-850 p-1">
|
||||
{runStatusIcon(step.status, "large")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,7 +424,7 @@ function StepHeader({ step }: { step: Step }) {
|
||||
<img
|
||||
src={step.service.integration.icon}
|
||||
alt={step.service.integration.name}
|
||||
className="h-5 w-5 mr-1"
|
||||
className="mr-1 h-5 w-5"
|
||||
/>
|
||||
}
|
||||
title={step.service.integration.name}
|
||||
@@ -473,6 +474,8 @@ function StepBody({ step }: { step: Step }) {
|
||||
switch (step.type) {
|
||||
case "CUSTOM_EVENT":
|
||||
return <CustomEventStep event={step} />;
|
||||
case "RUN_ONCE":
|
||||
return <RunOnceStep event={step} />;
|
||||
case "INTEGRATION_REQUEST":
|
||||
return <IntegrationRequestStep request={step} />;
|
||||
case "FETCH_REQUEST":
|
||||
@@ -584,7 +587,7 @@ function DelayScheduled({
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 text-slate-300">
|
||||
<div className="flex flex-col gap-1 items-stretch">
|
||||
<div className="flex flex-col items-stretch gap-1">
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Fires at
|
||||
</Body>
|
||||
@@ -621,7 +624,7 @@ function CustomEventStep({ event }: { event: StepType<Step, "CUSTOM_EVENT"> }) {
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Name
|
||||
</Body>
|
||||
<Header2 size="small" className="text-slate-300 mb-2">
|
||||
<Header2 size="small" className="mb-2 text-slate-300">
|
||||
{event.input.name}
|
||||
</Header2>
|
||||
{"delay" in event.input && event.input.delay && (
|
||||
@@ -629,7 +632,7 @@ function CustomEventStep({ event }: { event: StepType<Step, "CUSTOM_EVENT"> }) {
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Delay
|
||||
</Body>
|
||||
<Body size="small" className="text-slate-300 mb-2">
|
||||
<Body size="small" className="mb-2 text-slate-300">
|
||||
{"seconds" in event.input.delay ? (
|
||||
<>
|
||||
{event.input.delay.seconds}{" "}
|
||||
@@ -670,6 +673,25 @@ function CustomEventStep({ event }: { event: StepType<Step, "CUSTOM_EVENT"> }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RunOnceStep({ event }: { event: StepType<Step, "RUN_ONCE"> }) {
|
||||
return (
|
||||
<>
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Idempotency Key
|
||||
</Body>
|
||||
<Header2 size="small" className="mb-2 text-slate-300">
|
||||
{event.idempotencyKey}
|
||||
</Header2>
|
||||
{event.output && (
|
||||
<>
|
||||
<Header4>Output</Header4>
|
||||
<CodeBlock code={stringifyCode(event.output)} align="top" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationRequestStep({
|
||||
request,
|
||||
}: {
|
||||
@@ -684,14 +706,14 @@ function IntegrationRequestStep({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header2 size="small" className="text-slate-300 mb-2">
|
||||
<Header2 size="small" className="mb-2 text-slate-300">
|
||||
{request.displayProperties.title}
|
||||
</Header2>
|
||||
{request.service.connection === null && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-md bg-rose-500/10 border border-rose-600 p-3 mb-2">
|
||||
<ExclamationCircleIcon className="h-6 w-6 mr-1 text-rose-500" />
|
||||
<div className="flex gap-2 items-center justify-between flex-wrap w-full">
|
||||
<div className="mb-2 flex items-center gap-2 rounded-md border border-rose-600 bg-rose-500/10 p-3">
|
||||
<ExclamationCircleIcon className="mr-1 h-6 w-6 text-rose-500" />
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-2">
|
||||
<Body>
|
||||
You need to connect to {request.service.integration.name} to
|
||||
continue this workflow.
|
||||
@@ -723,7 +745,7 @@ function IntegrationRequestStep({
|
||||
<div className="mt-4">
|
||||
{request.requestStatus === "ERROR" ? (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-2 mt-3 ">
|
||||
<div className="mb-2 mt-3 flex gap-2 ">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500" />
|
||||
<Body size="small" className="text-rose-500">
|
||||
Failed with error:
|
||||
@@ -793,7 +815,7 @@ function FetchRequestStep({
|
||||
<div className="mt-4">
|
||||
{request.requestStatus === "ERROR" ? (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-2 mt-3 ">
|
||||
<div className="mb-2 mt-3 flex gap-2 ">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500" />
|
||||
<Body size="small" className="text-rose-500">
|
||||
Failed with error:
|
||||
@@ -808,7 +830,7 @@ function FetchRequestStep({
|
||||
</div>
|
||||
) : request.requestStatus === "RETRYING" ? (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-2 mt-3 ">
|
||||
<div className="mb-2 mt-3 flex gap-2 ">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500" />
|
||||
<Body size="small" className="text-rose-500">
|
||||
{request.lastResponse ? (
|
||||
@@ -856,7 +878,7 @@ function Error({ error }: { error: Run["error"] }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 mb-2 mt-3 ">
|
||||
<div className="mb-2 mt-3 flex gap-2 ">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500" />
|
||||
<Body size="small" className="text-slate-300">
|
||||
Failed with error:
|
||||
@@ -903,6 +925,10 @@ const stepInfo: Record<Step["type"], { label: string; icon: ReactNode }> = {
|
||||
label: "Fetch request",
|
||||
icon: <GlobeAltIcon className={styleClass} />,
|
||||
},
|
||||
RUN_ONCE: {
|
||||
label: "Run once",
|
||||
icon: <KeyIcon className={styleClass} />,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type LogLevel = StepType<Step, "LOG_MESSAGE">["input"]["level"];
|
||||
@@ -921,16 +947,16 @@ function renderCustomComponent({
|
||||
input: z.infer<typeof resendSchemas.SendEmailBodySchema>;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-md">
|
||||
<div className="flex px-2 h-8 items-center bg-slate-100 rounded-t-md">
|
||||
<div className="flex h-8 gap-2 items-center bg-slate-100 rounded-t-md">
|
||||
<div className="rounded-full bg-rose-500 w-3 h-3"></div>
|
||||
<div className="rounded-full bg-orange-500 w-3 h-3"></div>
|
||||
<div className="rounded-full bg-emerald-500 w-3 h-3"></div>
|
||||
<div className="rounded-md bg-white">
|
||||
<div className="flex h-8 items-center rounded-t-md bg-slate-100 px-2">
|
||||
<div className="flex h-8 items-center gap-2 rounded-t-md bg-slate-100">
|
||||
<div className="h-3 w-3 rounded-full bg-rose-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-orange-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-2 border-b border-slate-300">
|
||||
<h2 className="text-lg text-slate-600 font-bold">{input.from}</h2>
|
||||
<div className="border-b border-slate-300 px-4 py-2">
|
||||
<h2 className="text-lg font-bold text-slate-600">{input.from}</h2>
|
||||
<h2 className="text-slate-600">{input.subject}</h2>
|
||||
<div className="flex gap-2">
|
||||
<EmailInfo label="to" value={input.to} />
|
||||
@@ -966,7 +992,7 @@ function EmailInfo({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-slate-500 text-sm flex items-baseline gap-2">
|
||||
<div className="flex items-baseline gap-2 text-sm text-slate-500">
|
||||
<h3 className="text-slate-400">{label}:</h3>
|
||||
{typeof value === "string" ? value : value.join(", ")}
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,9 @@ import { WorkflowRunTriggerTimeout } from "./runs/runTriggerTimeout.server";
|
||||
import { DeliverScheduledEvent } from "./scheduler/deliverScheduledEvent.server";
|
||||
import { RegisterSchedulerSource } from "./scheduler/registerSchedulerSource.server";
|
||||
import { omit } from "~/utils/objects";
|
||||
import { findWorkflowStepById } from "~/models/workflowRunStep.server";
|
||||
import { InitializeRunOnce } from "./runOnce/initializeRunOnce.server";
|
||||
import { CompleteRunOnce } from "./runOnce/completeRunOnce.server";
|
||||
|
||||
let pulsarClient: PulsarClient;
|
||||
let triggerPublisher: ZodPublisher<TriggerCatalog>;
|
||||
@@ -271,6 +274,25 @@ function createCommandSubscriber() {
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
INITIALIZE_RUN_ONCE: async (id, data, properties) => {
|
||||
const service = new InitializeRunOnce();
|
||||
|
||||
await service.call(
|
||||
properties["x-workflow-run-id"],
|
||||
data.key,
|
||||
properties["x-timestamp"],
|
||||
data.runOnce
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
COMPLETE_RUN_ONCE: async (id, data, properties) => {
|
||||
const service = new CompleteRunOnce();
|
||||
|
||||
await service.call(data.runOnce);
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
@@ -434,6 +456,10 @@ const taskQueueCatalog = {
|
||||
data: CustomEventSchema.extend({ id: z.string() }),
|
||||
properties: z.object({}),
|
||||
},
|
||||
RESOLVE_RUN_ONCE: {
|
||||
data: z.object({ stepId: z.string(), hasRun: z.boolean() }),
|
||||
properties: z.object({}),
|
||||
},
|
||||
};
|
||||
|
||||
function createTaskQueue() {
|
||||
@@ -622,6 +648,37 @@ function createTaskQueue() {
|
||||
|
||||
return true;
|
||||
},
|
||||
RESOLVE_RUN_ONCE: async (id, data, properties) => {
|
||||
const step = await findWorkflowStepById(data.stepId);
|
||||
|
||||
if (!step) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = await commandResponsePublisher.publish(
|
||||
"RESOLVE_RUN_ONCE",
|
||||
{
|
||||
id: step.id,
|
||||
key: step.idempotencyKey,
|
||||
runOnce: {
|
||||
idempotencyKey: step.id,
|
||||
output: step.output
|
||||
? JSON.parse(JSON.stringify(step.output))
|
||||
: undefined,
|
||||
hasRun: data.hasRun,
|
||||
},
|
||||
},
|
||||
{
|
||||
"x-workflow-run-id": step.run.id,
|
||||
"x-api-key": step.run.environment.apiKey,
|
||||
"x-org-id": step.run.environment.organizationId,
|
||||
"x-workflow-id": step.run.workflowId,
|
||||
"x-env": step.run.environment.slug,
|
||||
}
|
||||
);
|
||||
|
||||
return !!response;
|
||||
},
|
||||
EXTERNAL_SOURCE_UPSERTED: async (id, data, properties) => {
|
||||
const service = new RegisterExternalSource();
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CompleteRunOnceSchema } from "@trigger.dev/common-schemas";
|
||||
import type { z } from "zod";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
type RunOnce = z.infer<typeof CompleteRunOnceSchema>;
|
||||
|
||||
export class CompleteRunOnce {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async call(runOnce: RunOnce) {
|
||||
return this.#prismaClient.workflowRunStep.updateMany({
|
||||
where: {
|
||||
id: runOnce.idempotencyKey,
|
||||
status: "RUNNING",
|
||||
},
|
||||
data: {
|
||||
status: "SUCCESS",
|
||||
finishedAt: new Date(),
|
||||
output:
|
||||
runOnce.type === "REMOTE" && typeof runOnce.output === "string"
|
||||
? safeOutputParse(runOnce.output)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function safeOutputParse(output?: string) {
|
||||
if (typeof output !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { InitializeRunOnceSchema } from "@trigger.dev/common-schemas";
|
||||
import type { z } from "zod";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import type { WorkflowRunStep } from "~/models/workflowRun.server";
|
||||
import { createStepOnce } from "~/models/workflowRunStep.server";
|
||||
import { taskQueue } from "../messageBroker.server";
|
||||
|
||||
type RunOnce = z.infer<typeof InitializeRunOnceSchema>;
|
||||
|
||||
export class InitializeRunOnce {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async call(runId: string, key: string, timestamp: string, runOnce: RunOnce) {
|
||||
const idempotentStep = await createStepOnce(runId, key, {
|
||||
type: "RUN_ONCE",
|
||||
status: "RUNNING",
|
||||
startedAt: new Date(),
|
||||
context: {},
|
||||
ts: timestamp,
|
||||
});
|
||||
|
||||
if (idempotentStep.status === "EXISTING") {
|
||||
return this.#handleExistingStep(idempotentStep.step);
|
||||
}
|
||||
|
||||
const workflowStep = idempotentStep.step;
|
||||
|
||||
return this.#handleNewStep(workflowStep, runOnce);
|
||||
}
|
||||
|
||||
async #handleExistingStep(step: WorkflowRunStep) {
|
||||
return taskQueue.publish(
|
||||
"RESOLVE_RUN_ONCE",
|
||||
{
|
||||
stepId: step.id,
|
||||
hasRun: true,
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
async #handleNewStep(step: WorkflowRunStep, runOnce: RunOnce) {
|
||||
if (runOnce.type === "LOCAL_ONLY") {
|
||||
await this.#prismaClient.workflowRunStep.update({
|
||||
where: {
|
||||
id: step.id,
|
||||
},
|
||||
data: {
|
||||
status: "SUCCESS",
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return taskQueue.publish(
|
||||
"RESOLVE_RUN_ONCE",
|
||||
{
|
||||
stepId: step.id,
|
||||
hasRun: false,
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "WorkflowRunStepType" ADD VALUE 'RUN_ONCE';
|
||||
@@ -510,6 +510,7 @@ enum WorkflowRunStepType {
|
||||
INTEGRATION_REQUEST
|
||||
DISCONNECTION
|
||||
FETCH_REQUEST
|
||||
RUN_ONCE
|
||||
}
|
||||
|
||||
model FetchRequest {
|
||||
|
||||
@@ -115,6 +115,28 @@ export class WorkflowRunController {
|
||||
|
||||
return success;
|
||||
},
|
||||
RESOLVE_RUN_ONCE: async (id, data, properties) => {
|
||||
if (properties["x-workflow-run-id"] !== this.#runId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.#logger.debug("Received resolve runOnce", id, data, properties);
|
||||
|
||||
const success = await this.#hostRPC.send("RESOLVE_RUN_ONCE", {
|
||||
id: data.id,
|
||||
output: data.runOnce,
|
||||
key: data.key,
|
||||
meta: {
|
||||
workflowId: properties["x-workflow-id"],
|
||||
organizationId: properties["x-org-id"],
|
||||
environment: properties["x-env"],
|
||||
apiKey: properties["x-api-key"],
|
||||
runId: properties["x-workflow-run-id"],
|
||||
},
|
||||
});
|
||||
|
||||
return success;
|
||||
},
|
||||
REJECT_INTEGRATION_REQUEST: async (id, data, properties) => {
|
||||
if (properties["x-workflow-run-id"] !== this.#runId) {
|
||||
return true;
|
||||
|
||||
@@ -169,6 +169,36 @@ export class TriggerServer {
|
||||
|
||||
return !!response;
|
||||
},
|
||||
INITIALIZE_RUN_ONCE: async (request) => {
|
||||
const runController = this.#runControllers.get(request.runId);
|
||||
|
||||
if (!runController) {
|
||||
// TODO: need to recover from this issue by trying to reconnect
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await runController.publish("INITIALIZE_RUN_ONCE", {
|
||||
key: request.key,
|
||||
runOnce: request.runOnce,
|
||||
});
|
||||
|
||||
return !!response;
|
||||
},
|
||||
COMPLETE_RUN_ONCE: async (request) => {
|
||||
const runController = this.#runControllers.get(request.runId);
|
||||
|
||||
if (!runController) {
|
||||
// TODO: need to recover from this issue by trying to reconnect
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await runController.publish("COMPLETE_RUN_ONCE", {
|
||||
key: request.key,
|
||||
runOnce: request.runOnce,
|
||||
});
|
||||
|
||||
return !!response;
|
||||
},
|
||||
SEND_EVENT: async (request) => {
|
||||
const runController = this.#runControllers.get(request.runId);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@examples/run-once",
|
||||
"version": "0.0.1",
|
||||
"description": "An example for how to use runOnce to perform idempotent operations",
|
||||
"dependencies": {
|
||||
"@trigger.dev/sdk": "workspace:*",
|
||||
"ulid": "^2.3.0",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trigger.dev/tsconfig": "workspace:*",
|
||||
"@types/node": "16",
|
||||
"tsx": "^3.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { customEvent, Trigger } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
class ExampleIdempotentService {
|
||||
private keyCounts = new Map<string, number>();
|
||||
private users = new Map<string, any>();
|
||||
|
||||
async updateUser(
|
||||
idempotencyKey: string,
|
||||
id: string,
|
||||
updates: Record<string, string>
|
||||
) {
|
||||
console.log("runOnce callback called", { idempotencyKey });
|
||||
|
||||
const count = this.keyCounts.get(idempotencyKey) || 0;
|
||||
|
||||
if (count > 0) {
|
||||
return this.users.get(id);
|
||||
}
|
||||
|
||||
this.keyCounts.set(idempotencyKey, count + 1);
|
||||
|
||||
console.log("Updating user", { id, updates, idempotencyKey });
|
||||
|
||||
const user = {
|
||||
id,
|
||||
...updates,
|
||||
updateCount: count + 1, // updateCount should never be > 1 (or else idempotency failed)
|
||||
};
|
||||
|
||||
this.users.set(id, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateUserWithErrors(
|
||||
idempotencyKey: string,
|
||||
id: string,
|
||||
updates: Record<string, string>
|
||||
) {
|
||||
throw new Error("This is an error");
|
||||
}
|
||||
}
|
||||
|
||||
const service = new ExampleIdempotentService();
|
||||
|
||||
new Trigger({
|
||||
id: "run-once",
|
||||
name: "Run once examples",
|
||||
apiKey: "trigger_dev_zC25mKNn6c0q",
|
||||
endpoint: "ws://localhost:8889/ws",
|
||||
on: customEvent({
|
||||
name: "update.user",
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
updates: z.record(z.string()),
|
||||
throwError: z.boolean().default(false),
|
||||
}),
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const output1 = await ctx.runOnce("update-user-once", async (key) => {
|
||||
return service.updateUser(key, event.id, event.updates);
|
||||
});
|
||||
|
||||
await ctx.logger.info("Updated the user once", {
|
||||
output1,
|
||||
});
|
||||
|
||||
const output2 = await ctx.runOnce("update-user-once", async (key) => {
|
||||
return service.updateUser(key, event.id, event.updates);
|
||||
});
|
||||
|
||||
await ctx.logger.info("Updated the user twice", {
|
||||
output2,
|
||||
});
|
||||
|
||||
const output3 = await ctx.runOnce("update-user-twice", async (key) => {
|
||||
return service.updateUser(key, event.id, event.updates);
|
||||
});
|
||||
|
||||
await ctx.logger.info("Updated the user thrice", {
|
||||
output3,
|
||||
});
|
||||
|
||||
const output4 = await ctx.runOnceLocalOnly(
|
||||
"update-user-local-only",
|
||||
async (key) => {
|
||||
return service.updateUser(key, event.id, event.updates);
|
||||
}
|
||||
);
|
||||
|
||||
await ctx.logger.info("Updated the user local only", {
|
||||
output4,
|
||||
});
|
||||
|
||||
const output5 = await ctx.runOnceLocalOnly(
|
||||
"update-user-local-only",
|
||||
async (key) => {
|
||||
return service.updateUser(key, event.id, event.updates);
|
||||
}
|
||||
);
|
||||
|
||||
await ctx.logger.info("Updated the user local only again", {
|
||||
output5,
|
||||
});
|
||||
|
||||
if (event.throwError) {
|
||||
await ctx.runOnce("update-user-error", async (key) => {
|
||||
return service.updateUserWithErrors(key, event.id, event.updates);
|
||||
});
|
||||
}
|
||||
|
||||
return { output1, output2, output3, output4, output5 };
|
||||
},
|
||||
}).listen();
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@trigger.dev/tsconfig/examples.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "**/*.test.*"]
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from "./waits";
|
||||
export * from "./events";
|
||||
export * from "./triggers";
|
||||
export * from "./fetch";
|
||||
export * from "./runOnce";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
import { SerializableJsonSchema } from "./json";
|
||||
|
||||
export const InitializeRunOnceSchema = z.object({
|
||||
type: z.enum(["REMOTE", "LOCAL_ONLY"]),
|
||||
});
|
||||
|
||||
export const CompleteRunOnceSchema = z.object({
|
||||
type: z.enum(["REMOTE", "LOCAL_ONLY"]),
|
||||
idempotencyKey: z.string(),
|
||||
output: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ResolveRunOnceOuputSchema = z.object({
|
||||
idempotencyKey: z.string(),
|
||||
hasRun: z.boolean(),
|
||||
output: SerializableJsonSchema.optional(),
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { FetchOutputSchema, JsonSchema } from "@trigger.dev/common-schemas";
|
||||
import {
|
||||
FetchOutputSchema,
|
||||
JsonSchema,
|
||||
ResolveRunOnceOuputSchema,
|
||||
} from "@trigger.dev/common-schemas";
|
||||
|
||||
export const HostRPCSchema = {
|
||||
TRIGGER_WORKFLOW: {
|
||||
@@ -94,6 +98,21 @@ export const HostRPCSchema = {
|
||||
}),
|
||||
response: z.boolean(),
|
||||
},
|
||||
RESOLVE_RUN_ONCE: {
|
||||
request: z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
output: ResolveRunOnceOuputSchema,
|
||||
meta: z.object({
|
||||
environment: z.string(),
|
||||
workflowId: z.string(),
|
||||
organizationId: z.string(),
|
||||
apiKey: z.string(),
|
||||
runId: z.string(),
|
||||
}),
|
||||
}),
|
||||
response: z.boolean(),
|
||||
},
|
||||
};
|
||||
|
||||
export type HostRPC = typeof HostRPCSchema;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
CompleteRunOnceSchema,
|
||||
CustomEventSchema,
|
||||
FetchRequestSchema,
|
||||
InitializeRunOnceSchema,
|
||||
RetrySchema,
|
||||
TriggerMetadataSchema,
|
||||
WaitSchema,
|
||||
@@ -110,6 +112,24 @@ export const ServerRPCSchema = {
|
||||
}),
|
||||
response: z.boolean(),
|
||||
},
|
||||
INITIALIZE_RUN_ONCE: {
|
||||
request: z.object({
|
||||
runId: z.string(),
|
||||
key: z.string(),
|
||||
timestamp: z.string(),
|
||||
runOnce: InitializeRunOnceSchema,
|
||||
}),
|
||||
response: z.boolean(),
|
||||
},
|
||||
COMPLETE_RUN_ONCE: {
|
||||
request: z.object({
|
||||
runId: z.string(),
|
||||
key: z.string(),
|
||||
timestamp: z.string(),
|
||||
runOnce: CompleteRunOnceSchema,
|
||||
}),
|
||||
response: z.boolean(),
|
||||
},
|
||||
};
|
||||
|
||||
export type ServerRPC = typeof ServerRPCSchema;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { commandResponses as integrationRequests } from "../schemas/integrationRequests";
|
||||
import { commandResponses as delays } from "../schemas/delays";
|
||||
import { commandResponses as fetchRequests } from "../schemas/fetchRequests";
|
||||
import { commandResponses as runOnce } from "../schemas/runOnce";
|
||||
|
||||
const Catalog = {
|
||||
...integrationRequests,
|
||||
...delays,
|
||||
...fetchRequests,
|
||||
...runOnce,
|
||||
};
|
||||
|
||||
export default Catalog;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { commands as logs } from "../schemas/logs";
|
||||
import { commands as customEvents } from "../schemas/customEvents";
|
||||
import { commands as delays } from "../schemas/delays";
|
||||
import { commands as fetchRequests } from "../schemas/fetchRequests";
|
||||
import { commands as runOnce } from "../schemas/runOnce";
|
||||
|
||||
const Catalog = {
|
||||
...integrationRequests,
|
||||
@@ -12,6 +13,7 @@ const Catalog = {
|
||||
...customEvents,
|
||||
...delays,
|
||||
...fetchRequests,
|
||||
...runOnce,
|
||||
};
|
||||
|
||||
export default Catalog;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
CompleteRunOnceSchema,
|
||||
FetchOutputSchema,
|
||||
FetchRequestSchema,
|
||||
InitializeRunOnceSchema,
|
||||
JsonSchema,
|
||||
ResolveRunOnceOuputSchema,
|
||||
RetrySchema,
|
||||
} from "@trigger.dev/common-schemas";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
WorkflowRunEventPropertiesSchema,
|
||||
WorkflowSendRunEventPropertiesSchema,
|
||||
} from "../sharedSchemas";
|
||||
|
||||
export const commandResponses = {
|
||||
RESOLVE_RUN_ONCE: {
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
runOnce: ResolveRunOnceOuputSchema,
|
||||
}),
|
||||
properties: WorkflowRunEventPropertiesSchema,
|
||||
},
|
||||
};
|
||||
|
||||
export const commands = {
|
||||
INITIALIZE_RUN_ONCE: {
|
||||
data: z.object({
|
||||
key: z.string(),
|
||||
runOnce: InitializeRunOnceSchema,
|
||||
}),
|
||||
properties: WorkflowSendRunEventPropertiesSchema,
|
||||
},
|
||||
COMPLETE_RUN_ONCE: {
|
||||
data: z.object({
|
||||
key: z.string(),
|
||||
runOnce: CompleteRunOnceSchema,
|
||||
}),
|
||||
properties: WorkflowSendRunEventPropertiesSchema,
|
||||
},
|
||||
};
|
||||
@@ -22,6 +22,8 @@ const zodErrorMessageOptions: ErrorMessageOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
type RunOnceOutput = { idempotencyKey: string; hasRun: boolean; output?: any };
|
||||
|
||||
export class TriggerClient<TSchema extends z.ZodTypeAny> {
|
||||
#trigger: Trigger<TSchema>;
|
||||
#options: TriggerOptions<TSchema>;
|
||||
@@ -61,6 +63,14 @@ export class TriggerClient<TSchema extends z.ZodTypeAny> {
|
||||
}
|
||||
>();
|
||||
|
||||
#runOnceCallbacks = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (output: RunOnceOutput) => void;
|
||||
reject: (err?: any) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(trigger: Trigger<TSchema>, options: TriggerOptions<TSchema>) {
|
||||
this.#trigger = trigger;
|
||||
this.#options = options;
|
||||
@@ -200,6 +210,30 @@ export class TriggerClient<TSchema extends z.ZodTypeAny> {
|
||||
|
||||
return true;
|
||||
},
|
||||
RESOLVE_RUN_ONCE: async (data) => {
|
||||
this.#logger.debug("Handling RESOLVE_RUN_ONCE", data);
|
||||
|
||||
const runOnceCallbacks = this.#runOnceCallbacks.get(
|
||||
messageKey(data.meta.runId, data.key)
|
||||
);
|
||||
|
||||
if (!runOnceCallbacks) {
|
||||
this.#logger.debug(
|
||||
`Could not find runOnce callbacks for request ID ${messageKey(
|
||||
data.meta.runId,
|
||||
data.key
|
||||
)}. This can happen when a workflow run is resumed`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const { resolve } = runOnceCallbacks;
|
||||
|
||||
resolve(data.output);
|
||||
|
||||
return true;
|
||||
},
|
||||
RESOLVE_REQUEST: async (data) => {
|
||||
this.#logger.debug("Handling RESOLVE_REQUEST", data);
|
||||
|
||||
@@ -425,6 +459,67 @@ export class TriggerClient<TSchema extends z.ZodTypeAny> {
|
||||
|
||||
return;
|
||||
},
|
||||
runOnce: async (key, callback) => {
|
||||
const result = new Promise<RunOnceOutput>((resolve, reject) => {
|
||||
this.#runOnceCallbacks.set(messageKey(data.id, key), {
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
});
|
||||
|
||||
await serverRPC.send("INITIALIZE_RUN_ONCE", {
|
||||
runId: data.id,
|
||||
key,
|
||||
runOnce: {
|
||||
type: "REMOTE",
|
||||
},
|
||||
timestamp: String(highPrecisionTimestamp()),
|
||||
});
|
||||
|
||||
const { idempotencyKey, hasRun, output } = await result;
|
||||
|
||||
if (hasRun) {
|
||||
return output;
|
||||
}
|
||||
|
||||
const callbackResult = await callback(idempotencyKey);
|
||||
|
||||
await serverRPC.send("COMPLETE_RUN_ONCE", {
|
||||
runId: data.id,
|
||||
key,
|
||||
runOnce: {
|
||||
type: "REMOTE",
|
||||
idempotencyKey,
|
||||
output: callbackResult
|
||||
? JSON.stringify(callbackResult)
|
||||
: undefined,
|
||||
},
|
||||
timestamp: String(highPrecisionTimestamp()),
|
||||
});
|
||||
|
||||
return callbackResult;
|
||||
},
|
||||
runOnceLocalOnly: async (key, callback) => {
|
||||
const result = new Promise<RunOnceOutput>((resolve, reject) => {
|
||||
this.#runOnceCallbacks.set(messageKey(data.id, key), {
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
});
|
||||
|
||||
await serverRPC.send("INITIALIZE_RUN_ONCE", {
|
||||
runId: data.id,
|
||||
key,
|
||||
runOnce: {
|
||||
type: "LOCAL_ONLY",
|
||||
},
|
||||
timestamp: String(highPrecisionTimestamp()),
|
||||
});
|
||||
|
||||
const { idempotencyKey } = await result;
|
||||
|
||||
return callback(idempotencyKey);
|
||||
},
|
||||
fetch: fetchFunction,
|
||||
};
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ export type TriggerFetch = <TBodySchema extends z.ZodTypeAny = z.ZodTypeAny>(
|
||||
options: FetchOptions<TBodySchema>
|
||||
) => Promise<FetchResponse<TBodySchema>>;
|
||||
|
||||
export type TriggerRunOnceCallback = (idempotencyKey: string) => Promise<any>;
|
||||
|
||||
export interface TriggerContext {
|
||||
id: string;
|
||||
environment: string;
|
||||
@@ -66,6 +68,14 @@ export interface TriggerContext {
|
||||
sendEvent(key: string, event: TriggerCustomEvent): Promise<void>;
|
||||
waitFor(key: string, options: WaitForOptions): Promise<void>;
|
||||
waitUntil(key: string, date: Date): Promise<void>;
|
||||
runOnce<T extends TriggerRunOnceCallback>(
|
||||
key: string,
|
||||
callback: T
|
||||
): Promise<Awaited<ReturnType<T>>>;
|
||||
runOnceLocalOnly<T extends TriggerRunOnceCallback>(
|
||||
key: string,
|
||||
callback: T
|
||||
): Promise<Awaited<ReturnType<T>>>;
|
||||
fetch: TriggerFetch;
|
||||
}
|
||||
|
||||
|
||||
Generated
+54
-25
@@ -190,7 +190,7 @@ importers:
|
||||
'@aws-sdk/client-s3': 3.245.0
|
||||
'@aws-sdk/s3-request-presigner': 3.245.0
|
||||
'@cfworker/json-schema': 1.12.5
|
||||
'@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde
|
||||
'@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu
|
||||
'@codemirror/commands': 6.1.3
|
||||
'@codemirror/lang-javascript': 6.1.2
|
||||
'@codemirror/lang-json': 6.0.1
|
||||
@@ -220,7 +220,7 @@ importers:
|
||||
'@trigger.dev/slack': link:../../integrations/slack
|
||||
'@trigger.dev/whatsapp': link:../../integrations/whatsapp
|
||||
'@typeform/embed-react': 2.14.1_react@18.2.0
|
||||
'@uiw/react-codemirror': 4.19.5_aguurb4bmecpxzejz52amioxne
|
||||
'@uiw/react-codemirror': 4.19.5_k4ec5g7vuuzzonc3d6xbjnmmle
|
||||
bcryptjs: 2.4.3
|
||||
classnames: 2.3.2
|
||||
clsx: 1.2.1
|
||||
@@ -495,6 +495,23 @@ importers:
|
||||
react-email: 1.6.1_react@18.2.0
|
||||
tsx: 3.12.2
|
||||
|
||||
examples/run-once-example:
|
||||
specifiers:
|
||||
'@trigger.dev/sdk': workspace:*
|
||||
'@trigger.dev/tsconfig': workspace:*
|
||||
'@types/node': '16'
|
||||
tsx: ^3.12.0
|
||||
ulid: ^2.3.0
|
||||
zod: ^3.20.2
|
||||
dependencies:
|
||||
'@trigger.dev/sdk': link:../../packages/trigger-sdk
|
||||
ulid: 2.3.0
|
||||
zod: 3.20.2
|
||||
devDependencies:
|
||||
'@trigger.dev/tsconfig': link:../../config-packages/tsconfig
|
||||
'@types/node': 16.18.11
|
||||
tsx: 3.12.2
|
||||
|
||||
examples/schedule-to-slack:
|
||||
specifiers:
|
||||
'@trigger.dev/sdk': workspace:*
|
||||
@@ -640,7 +657,7 @@ importers:
|
||||
tsup: ^6.5.0
|
||||
zod: ^3.20.2
|
||||
dependencies:
|
||||
'@react-email/render': 0.0.3_react@18.2.0
|
||||
'@react-email/render': 0.0.3
|
||||
debug: 4.3.4
|
||||
zod: 3.20.2
|
||||
devDependencies:
|
||||
@@ -3598,13 +3615,12 @@ packages:
|
||||
prettier: 2.8.2
|
||||
dev: false
|
||||
|
||||
/@codemirror/autocomplete/6.4.0_eo6pz6bvsllvatnnwfprpuflde:
|
||||
/@codemirror/autocomplete/6.4.0_czcfkg2f66rxeiodoti7r2gulu:
|
||||
resolution: {integrity: sha512-HLF2PnZAm1s4kGs30EiqKMgD7XsYaQ0XJnMR0rofEWQ5t5D60SfqpDIkIh1ze5tiEbyUWm8+VJ6W1/erVvBMIA==}
|
||||
peerDependencies:
|
||||
'@codemirror/language': ^6.0.0
|
||||
'@codemirror/state': ^6.0.0
|
||||
'@codemirror/view': ^6.0.0
|
||||
'@lezer/common': ^1.0.0
|
||||
dependencies:
|
||||
'@codemirror/language': 6.3.2
|
||||
'@codemirror/state': 6.2.0
|
||||
@@ -3624,7 +3640,7 @@ packages:
|
||||
/@codemirror/lang-javascript/6.1.2:
|
||||
resolution: {integrity: sha512-OcwLfZXdQ1OHrLiIcKCn7MqZ7nx205CMKlhe+vL88pe2ymhT9+2P+QhwkYGxMICj8TDHyp8HFKVwpiisUT7iEQ==}
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde
|
||||
'@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu
|
||||
'@codemirror/language': 6.3.2
|
||||
'@codemirror/lint': 6.1.0
|
||||
'@codemirror/state': 6.2.0
|
||||
@@ -4822,6 +4838,16 @@ packages:
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
dev: false
|
||||
|
||||
/@react-email/render/0.0.3:
|
||||
resolution: {integrity: sha512-+4eOrLGdTCJjoJU3PunekErjo3PFnhSDFjVINBHrfJT+1wlVdhWfDo7hFdzXJx/JOOYqmnZTilU/umGLdRumKQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
dependencies:
|
||||
pretty: 2.0.0
|
||||
react-dom: 18.2.0
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
dev: false
|
||||
|
||||
/@react-email/render/0.0.3_react@18.2.0:
|
||||
resolution: {integrity: sha512-+4eOrLGdTCJjoJU3PunekErjo3PFnhSDFjVINBHrfJT+1wlVdhWfDo7hFdzXJx/JOOYqmnZTilU/umGLdRumKQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -4937,7 +4963,7 @@ packages:
|
||||
eslint: 8.31.0
|
||||
eslint-import-resolver-node: 0.3.6
|
||||
eslint-import-resolver-typescript: 3.5.3_hnftvkj7qg3s6bbigj4pr6djxy
|
||||
eslint-plugin-import: 2.27.4_qdjeohovcytra7xto5vgmxssaq
|
||||
eslint-plugin-import: 2.27.4_2ac3tknkazjoq5fxmuugu665ny
|
||||
eslint-plugin-jest: 26.9.0_ohsifnwenhmxgcp7mend4dnv74
|
||||
eslint-plugin-jest-dom: 4.0.3_eslint@8.31.0
|
||||
eslint-plugin-jsx-a11y: 6.7.1_eslint@8.31.0
|
||||
@@ -6074,18 +6100,17 @@ packages:
|
||||
eslint-visitor-keys: 3.3.0
|
||||
dev: true
|
||||
|
||||
/@uiw/codemirror-extensions-basic-setup/4.19.5_tbeldtdcrf45b35pezgkzq2u4e:
|
||||
/@uiw/codemirror-extensions-basic-setup/4.19.5_wd2tsis3in55bkaiwnc2c46tom:
|
||||
resolution: {integrity: sha512-1zt7ZPJ01xKkSW/KDy0FZNga0bngN1fC594wCVG7FBi60ehfcAucpooQ+JSPScKXopxcb+ugPKZvVLzr9/OfzA==}
|
||||
peerDependencies:
|
||||
'@codemirror/autocomplete': '>=6.0.0'
|
||||
'@codemirror/commands': '>=6.0.0'
|
||||
'@codemirror/language': '>=6.0.0'
|
||||
'@codemirror/lint': '>=6.0.0'
|
||||
'@codemirror/search': '>=6.0.0'
|
||||
'@codemirror/state': '>=6.0.0'
|
||||
'@codemirror/view': '>=6.0.0'
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde
|
||||
'@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu
|
||||
'@codemirror/commands': 6.1.3
|
||||
'@codemirror/language': 6.3.2
|
||||
'@codemirror/lint': 6.1.0
|
||||
@@ -6094,14 +6119,11 @@ packages:
|
||||
'@codemirror/view': 6.7.2
|
||||
dev: false
|
||||
|
||||
/@uiw/react-codemirror/4.19.5_aguurb4bmecpxzejz52amioxne:
|
||||
/@uiw/react-codemirror/4.19.5_k4ec5g7vuuzzonc3d6xbjnmmle:
|
||||
resolution: {integrity: sha512-ZCHh8d7beXbF8/t7F1+yHht6A9Y6CdKeOkZq4A09lxJEnyTQrj1FMf2zvfaqc7K23KNjkTCtSlbqKKbVDgrWaw==}
|
||||
peerDependencies:
|
||||
'@babel/runtime': '>=7.11.0'
|
||||
'@codemirror/state': '>=6.0.0'
|
||||
'@codemirror/theme-one-dark': '>=6.0.0'
|
||||
'@codemirror/view': '>=6.0.0'
|
||||
codemirror: '>=6.0.0'
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
dependencies:
|
||||
@@ -6110,14 +6132,13 @@ packages:
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/theme-one-dark': 6.1.0
|
||||
'@codemirror/view': 6.7.2
|
||||
'@uiw/codemirror-extensions-basic-setup': 4.19.5_tbeldtdcrf45b35pezgkzq2u4e
|
||||
codemirror: 6.0.1_@lezer+common@1.0.2
|
||||
'@uiw/codemirror-extensions-basic-setup': 4.19.5_wd2tsis3in55bkaiwnc2c46tom
|
||||
codemirror: 6.0.1
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/autocomplete'
|
||||
- '@codemirror/language'
|
||||
- '@codemirror/lint'
|
||||
- '@codemirror/search'
|
||||
dev: false
|
||||
|
||||
@@ -7428,18 +7449,16 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/codemirror/6.0.1_@lezer+common@1.0.2:
|
||||
/codemirror/6.0.1:
|
||||
resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==}
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde
|
||||
'@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu
|
||||
'@codemirror/commands': 6.1.3
|
||||
'@codemirror/language': 6.3.2
|
||||
'@codemirror/lint': 6.1.0
|
||||
'@codemirror/search': 6.2.3
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/view': 6.7.2
|
||||
transitivePeerDependencies:
|
||||
- '@lezer/common'
|
||||
dev: false
|
||||
|
||||
/collection-visit/1.0.0:
|
||||
@@ -8775,7 +8794,7 @@ packages:
|
||||
debug: 4.3.4
|
||||
enhanced-resolve: 5.12.0
|
||||
eslint: 8.31.0
|
||||
eslint-plugin-import: 2.27.4_qdjeohovcytra7xto5vgmxssaq
|
||||
eslint-plugin-import: 2.27.4_2ac3tknkazjoq5fxmuugu665ny
|
||||
get-tsconfig: 4.3.0
|
||||
globby: 13.1.3
|
||||
is-core-module: 2.11.0
|
||||
@@ -8785,7 +8804,7 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/eslint-module-utils/2.7.4_sqt5xxn4ciiurbqrzlaarm6ama:
|
||||
/eslint-module-utils/2.7.4_v73lhamtbyinynmwa5fn7kpmfq:
|
||||
resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==}
|
||||
engines: {node: '>=4'}
|
||||
peerDependencies:
|
||||
@@ -8810,6 +8829,7 @@ packages:
|
||||
debug: 3.2.7
|
||||
eslint: 8.31.0
|
||||
eslint-import-resolver-node: 0.3.7
|
||||
eslint-import-resolver-typescript: 3.5.3_hnftvkj7qg3s6bbigj4pr6djxy
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
@@ -8834,7 +8854,7 @@ packages:
|
||||
regexpp: 3.2.0
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-import/2.27.4_qdjeohovcytra7xto5vgmxssaq:
|
||||
/eslint-plugin-import/2.27.4_2ac3tknkazjoq5fxmuugu665ny:
|
||||
resolution: {integrity: sha512-Z1jVt1EGKia1X9CnBCkpAOhWy8FgQ7OmJ/IblEkT82yrFU/xJaxwujaTzLWqigewwynRQ9mmHfX9MtAfhxm0sA==}
|
||||
engines: {node: '>=4'}
|
||||
peerDependencies:
|
||||
@@ -8852,7 +8872,7 @@ packages:
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.31.0
|
||||
eslint-import-resolver-node: 0.3.7
|
||||
eslint-module-utils: 2.7.4_sqt5xxn4ciiurbqrzlaarm6ama
|
||||
eslint-module-utils: 2.7.4_v73lhamtbyinynmwa5fn7kpmfq
|
||||
has: 1.0.3
|
||||
is-core-module: 2.11.0
|
||||
is-glob: 4.0.3
|
||||
@@ -13954,6 +13974,15 @@ packages:
|
||||
shallow-equal: 1.2.1
|
||||
dev: false
|
||||
|
||||
/react-dom/18.2.0:
|
||||
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
scheduler: 0.23.0
|
||||
dev: false
|
||||
|
||||
/react-dom/18.2.0_react@18.2.0:
|
||||
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
||||
peerDependencies:
|
||||
|
||||
Reference in New Issue
Block a user