Implement runOnce and runOnceLocalOnly functions

This commit is contained in:
Eric Allam
2023-02-03 13:50:55 +00:00
parent f6fbd5e5b7
commit 0d2d9a043c
27 changed files with 908 additions and 84 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Added runOnce and runOnceLocalOnly to support running idempotent actions
+152
View File
@@ -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:
![Run Once Output](/images/run-once-output.png)
## 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
View File
@@ -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,
},
},
},
},
},
});
}
@@ -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';
+1
View File
@@ -510,6 +510,7 @@ enum WorkflowRunStepType {
INTEGRATION_REQUEST
DISCONNECTION
FETCH_REQUEST
RUN_ONCE
}
model FetchRequest {
+22
View File
@@ -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;
+30
View File
@@ -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);
+19
View File
@@ -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"
}
}
+115
View File
@@ -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();
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "@trigger.dev/tsconfig/examples.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "**/*.test.*"]
}
+1
View File
@@ -5,3 +5,4 @@ export * from "./waits";
export * from "./events";
export * from "./triggers";
export * from "./fetch";
export * from "./runOnce";
+18
View File
@@ -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(),
});
+20 -1
View File
@@ -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,
},
};
+95
View File
@@ -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,
};
+10
View File
@@ -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;
}
+54 -25
View File
@@ -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: