Indexing errors don't get displayed anywhere (#605)

* Added EndpointIndex status. Default is PENDING, existing rows are SUCCESS

* Made it easier to create a migration SQL file

* Created a job-catalog file for misconfigured Jobs that should error when running the CLI

* EndpointIndex data and state are now optional

* The Environments page now shows the status of the last refresh

* Improved the UI about endpoints

* Added EndpointIndex error column

* WIP making the endpoint indexing more robust

* Indexing errors are now surfaced

* Improved the error show it shows the job id

* Use “performEndpointIndexing” when you create your first endpoint from the UI

* Use “performEndpointIndexing” for the recurring endpoint checker

* Staging is now auto-indexed every 10 mins too

* Use “performEndpointIndexing” for the webhook

* Moved the throttling to a util

* Removed instructional comments

* Created a reusable retry system with exponential backoff

* Use p-retry for retrying with backoff

* The CLI gets indexing results and displays errors

* Improved the indexing error messages and display in the console

* Use a pre so the Indexing error is correctly split over multiple lines

* Use a db transaction for webhook that triggers endpoint indexing

* Tidied up imports

* Support older versions of the server

* Improved the comment on the misconfigured job

* Changeset: When indexing user's jobs errors are now stored and displayed
This commit is contained in:
Matt Aitken
2023-10-11 17:31:19 +01:00
committed by GitHub
parent 203a431350
commit 50e3d9e43a
37 changed files with 1101 additions and 481 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"@trigger.dev/cli": patch
---
When indexing user's jobs errors are now stored and displayed
@@ -0,0 +1,74 @@
import { CheckCircleIcon, ClockIcon, XCircleIcon } from "@heroicons/react/20/solid";
import { EndpointIndexStatus } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import { Spinner } from "../primitives/Spinner";
export function EndpointIndexStatusIcon({ status }: { status: EndpointIndexStatus }) {
switch (status) {
case "PENDING":
return <ClockIcon className={cn("h-4 w-4", endpointIndexStatusClassNameColor(status))} />;
case "STARTED":
return <Spinner className={cn("h-4 w-4", endpointIndexStatusClassNameColor(status))} />;
case "SUCCESS":
return (
<CheckCircleIcon className={cn("h-4 w-4", endpointIndexStatusClassNameColor(status))} />
);
case "FAILURE":
return <XCircleIcon className={cn("h-4 w-4", endpointIndexStatusClassNameColor(status))} />;
}
}
export function EndpointIndexStatusLabel({ status }: { status: EndpointIndexStatus }) {
switch (status) {
case "PENDING":
return (
<span className={endpointIndexStatusClassNameColor(status)}>
{endpointIndexStatusTitle(status)}
</span>
);
case "STARTED":
return (
<span className={endpointIndexStatusClassNameColor(status)}>
{endpointIndexStatusTitle(status)}
</span>
);
case "SUCCESS":
return (
<span className={endpointIndexStatusClassNameColor(status)}>
{endpointIndexStatusTitle(status)}
</span>
);
case "FAILURE":
return (
<span className={endpointIndexStatusClassNameColor(status)}>
{endpointIndexStatusTitle(status)}
</span>
);
}
}
export function endpointIndexStatusTitle(status: EndpointIndexStatus): string {
switch (status) {
case "PENDING":
return "Pending";
case "STARTED":
return "Started";
case "SUCCESS":
return "Success";
case "FAILURE":
return "Failure";
}
}
export function endpointIndexStatusClassNameColor(status: EndpointIndexStatus): string {
switch (status) {
case "PENDING":
return "text-dimmed";
case "STARTED":
return "text-blue-500";
case "SUCCESS":
return "text-green-500";
case "FAILURE":
return "text-rose-500";
}
}
@@ -13,6 +13,7 @@ import {
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { Paragraph } from "./Paragraph";
import { Spinner } from "./Spinner";
export const variantClasses = {
info: {
@@ -51,8 +52,16 @@ export const variantClasses = {
textColor: "text-blue-200",
linkClassName: "transition hover:bg-blue-400/40",
},
pending: {
className: "border-blue-400/20 bg-blue-800/30",
icon: <Spinner className="h-5 w-5 shrink-0 " />,
textColor: "text-blue-300",
linkClassName: "transition hover:bg-blue-400/40",
},
} as const;
export type CalloutVariant = keyof typeof variantClasses;
export function Callout({
children,
className,
@@ -63,7 +72,7 @@ export function Callout({
children?: React.ReactNode;
className?: string;
icon?: React.ReactNode;
variant: keyof typeof variantClasses;
variant: CalloutVariant;
to?: string;
}) {
const variantDefinition = variantClasses[variant];
@@ -2,8 +2,17 @@ import type { z } from "zod";
import { Paragraph } from "./Paragraph";
import { NamedIcon } from "./NamedIcon";
import { motion } from "framer-motion";
import { cn } from "~/utils/cn";
export function FormError({ children, id }: { children: React.ReactNode; id?: string }) {
export function FormError({
children,
id,
className,
}: {
children: React.ReactNode;
id?: string;
className?: string;
}) {
return (
<>
{children && (
@@ -11,7 +20,7 @@ export function FormError({ children, id }: { children: React.ReactNode; id?: st
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="flex items-start gap-0.5"
className={cn("flex items-start gap-0.5", className)}
>
<NamedIcon name="error" className="h-4 w-4 shrink-0 justify-start" />
<Paragraph id={id} variant="extra-small" className="text-rose-500">
@@ -1,14 +0,0 @@
import { z } from "zod";
const IndexEndpointStatsSchema = z.object({
jobs: z.number(),
sources: z.number(),
dynamicTriggers: z.number(),
dynamicSchedules: z.number(),
});
export type IndexEndpointStats = z.infer<typeof IndexEndpointStatsSchema>;
export function parseEndpointIndexStats(stats: unknown): IndexEndpointStats {
return IndexEndpointStatsSchema.parse(stats);
}
@@ -1,13 +1,19 @@
import { PrismaClient, prisma } from "~/db.server";
import { IndexEndpointStats, parseEndpointIndexStats } from "~/models/indexEndpoint.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import type {
Endpoint,
EndpointIndex,
EndpointIndexStatus,
RuntimeEnvironment,
RuntimeEnvironmentType,
} from "@trigger.dev/database";
import {
EndpointIndexError,
EndpointIndexErrorSchema,
IndexEndpointStats,
parseEndpointIndexStats,
} from "@trigger.dev/core";
export type Client = {
slug: string;
@@ -34,9 +40,11 @@ export type ClientEndpoint =
url: string;
indexWebhookPath: string;
latestIndex?: {
status: EndpointIndexStatus;
source: string;
updatedAt: Date;
stats: IndexEndpointStats;
stats?: IndexEndpointStats;
error?: EndpointIndexError;
};
environment: {
id: string;
@@ -81,9 +89,11 @@ export class EnvironmentsPresenter {
indexingHookIdentifier: true,
indexings: {
select: {
status: true,
source: true,
updatedAt: true,
stats: true,
error: true,
},
take: 1,
orderBy: {
@@ -214,7 +224,7 @@ const environmentSortOrder: RuntimeEnvironmentType[] = [
function endpointClient(
endpoint: Pick<Endpoint, "id" | "slug" | "url" | "indexingHookIdentifier"> & {
indexings: Pick<EndpointIndex, "source" | "updatedAt" | "stats">[];
indexings: Pick<EndpointIndex, "status" | "source" | "updatedAt" | "stats" | "error">[];
},
environment: Pick<RuntimeEnvironment, "id" | "apiKey" | "type">,
baseUrl: string
@@ -227,9 +237,13 @@ function endpointClient(
indexWebhookPath: `${baseUrl}/api/v1/endpoints/${environment.id}/${endpoint.slug}/index/${endpoint.indexingHookIdentifier}`,
latestIndex: endpoint.indexings[0]
? {
status: endpoint.indexings[0].status,
source: endpoint.indexings[0].source,
updatedAt: endpoint.indexings[0].updatedAt,
stats: parseEndpointIndexStats(endpoint.indexings[0].stats),
error: endpoint.indexings[0].error
? EndpointIndexErrorSchema.parse(endpoint.indexings[0].error)
: undefined,
}
: undefined,
environment: environment,
@@ -6,7 +6,7 @@ import { useEventSource } from "remix-utils";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Callout, CalloutVariant } from "~/components/primitives/Callout";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DateTime } from "~/components/primitives/DateTime";
import { FormError } from "~/components/primitives/FormError";
@@ -18,8 +18,14 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { Sheet, SheetBody, SheetContent, SheetHeader } from "~/components/primitives/Sheet";
import { ClientEndpoint } from "~/presenters/EnvironmentsPresenter.server";
import { endpointStreamingPath } from "~/utils/pathBuilder";
import { RuntimeEnvironmentType } from "../../../../../packages/database/src";
import { EndpointIndexStatus, RuntimeEnvironmentType } from "../../../../../packages/database/src";
import { bodySchema } from "../resources.environments.$environmentParam.endpoint";
import {
EndpointIndexStatusIcon,
EndpointIndexStatusLabel,
endpointIndexStatusTitle,
} from "~/components/environments/EndpointIndexStatus";
import { CodeBlock } from "~/components/code/CodeBlock";
type ConfigureEndpointSheetProps = {
slug: string;
@@ -119,15 +125,29 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
method="post"
action={`/resources/environments/${endpoint.environment.id}/endpoint/${endpoint.id}`}
>
<Callout variant="success" className="justiy-between items-center">
<Paragraph variant="small" className="grow text-green-200">
Endpoint configured. Last refreshed:{" "}
{endpoint.latestIndex ? (
<DateTime date={endpoint.latestIndex.updatedAt} />
) : (
""
)}
</Paragraph>
<Callout
variant="info"
icon={
<EndpointIndexStatusIcon status={endpoint.latestIndex?.status ?? "PENDING"} />
}
className="justiy-between items-center"
>
<div className="flex grow items-center gap-2">
<EndpointIndexStatusLabel
status={endpoint.latestIndex?.status ?? "PENDING"}
/>
<Paragraph variant="small" className="grow">
Last refreshed:{" "}
{endpoint.latestIndex ? (
<>
<DateTime date={endpoint.latestIndex.updatedAt} />
</>
) : (
""
)}
</Paragraph>
</div>
<Button
variant="primary/small"
type="submit"
@@ -138,6 +158,11 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
{refreshingEndpoint ? "Refreshing" : "Refresh now"}
</Button>
</Callout>
{endpoint.latestIndex?.error && (
<FormError className="p-2">
<pre>{endpoint.latestIndex.error.message}</pre>
</FormError>
)}
</refreshEndpointFetcher.Form>
</div>
<div className="max-w-full overflow-hidden">
@@ -155,3 +180,16 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
</Sheet>
);
}
function calloutVariantFromStatus(status: EndpointIndexStatus): CalloutVariant {
switch (status) {
case "PENDING":
return "pending";
case "STARTED":
return "pending";
case "SUCCESS":
return "success";
case "FAILURE":
return "error";
}
}
@@ -3,11 +3,16 @@ import { LoaderArgs } from "@remix-run/server-runtime";
import { useEffect, useMemo, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEventSource } from "remix-utils";
import {
EndpointIndexStatusIcon,
EndpointIndexStatusLabel,
} from "~/components/environments/EndpointIndexStatus";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
import { HowToUseApiKeysAndEndpoints } from "~/components/helpContent/HelpContentText";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { Button, ButtonContent } from "~/components/primitives/Buttons";
import { Badge } from "~/components/primitives/Badge";
import { ButtonContent } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
@@ -38,7 +43,6 @@ import { ProjectParamSchema, projectEnvironmentsStreamingPath } from "~/utils/pa
import { requestUrl } from "~/utils/requestUrl.server";
import { RuntimeEnvironmentType } from "../../../../../packages/database/src";
import { ConfigureEndpointSheet } from "./ConfigureEndpointSheet";
import { Badge } from "~/components/primitives/Badge";
import { FirstEndpointSheet } from "./FirstEndpointSheet";
export const loader = async ({ request, params }: LoaderArgs) => {
@@ -180,6 +184,7 @@ export default function Page() {
<TableHeaderCell>Environment</TableHeaderCell>
<TableHeaderCell>Url</TableHeaderCell>
<TableHeaderCell>Last refreshed</TableHeaderCell>
<TableHeaderCell>Last refresh Status</TableHeaderCell>
<TableHeaderCell>Jobs</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
@@ -268,7 +273,7 @@ function EndpointRow({
<EnvironmentLabel environment={{ type }} />
</div>
</TableCell>
<TableCell onClick={onClick} colSpan={4} alignment="right">
<TableCell onClick={onClick} colSpan={5} alignment="right">
<div className="flex items-center justify-end gap-4">
<span className="text-amber-500">
The {environmentTitle({ type })} environment is not configured
@@ -290,7 +295,17 @@ function EndpointRow({
<TableCell onClick={onClick}>
{endpoint.latestIndex ? <DateTime date={endpoint.latestIndex.updatedAt} /> : ""}
</TableCell>
<TableCell onClick={onClick}>{endpoint.latestIndex?.stats.jobs ?? ""}</TableCell>
<TableCell onClick={onClick}>
{endpoint.latestIndex ? (
<div className="flex items-center gap-1">
<EndpointIndexStatusIcon status={endpoint.latestIndex.status} />
<EndpointIndexStatusLabel status={endpoint.latestIndex.status} />
</div>
) : (
""
)}
</TableCell>
<TableCell onClick={onClick}>{endpoint.latestIndex?.stats?.jobs ?? ""}</TableCell>
<TableCellChevron onClick={onClick} />
</TableRow>
);
@@ -0,0 +1,68 @@
import { ActionArgs, json } from "@remix-run/server-runtime";
import {
EndpointIndexErrorSchema,
GetEndpointIndexResponse,
GetEndpointIndexResponseSchema,
} from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { IndexEndpointService } from "~/services/endpoints/indexEndpoint.server";
import { logger } from "~/services/logger.server";
const ParamsSchema = z.object({
indexId: z.string(),
});
export async function loader({ request, params }: ActionArgs) {
if (request.method.toUpperCase() !== "GET") {
return { status: 405, body: "Method Not Allowed" };
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const authenticatedEnv = authenticationResult.environment;
const { indexId } = parsedParams.data;
const endpointIndex = await prisma.endpointIndex.findUnique({
where: {
id: indexId,
endpoint: {
environmentId: authenticatedEnv.id,
},
},
});
if (!endpointIndex) {
logger.info("EndpointIndex not found", { url: request.url });
return json({ error: "EndpointIndex not found" }, { status: 404 });
}
const parsed = GetEndpointIndexResponseSchema.safeParse(endpointIndex);
if (!parsed.success) {
logger.info("EndpointIndex failed parsing", { errors: parsed.error.issues, endpointIndex });
const parseFailResult: GetEndpointIndexResponse = {
status: "FAILURE",
error: {
message: "Invalid endpoint index",
},
updatedAt: new Date(),
};
return json(parseFailResult, { status: 500 });
}
return json(parsed.data);
}
@@ -1,7 +1,7 @@
import { ActionArgs, LoaderArgs, json } from "@remix-run/server-runtime";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
import { $transaction, PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { workerQueue } from "~/services/worker.server";
import { safeJsonParse } from "~/utils/json";
@@ -93,43 +93,53 @@ export class TriggerEndpointIndexHookService {
body,
});
const endpoint = await this.#prismaClient.endpoint.findUnique({
where: {
environmentId_slug: {
environmentId,
slug: endpointSlug,
await $transaction(this.#prismaClient, async (tx) => {
const endpoint = await tx.endpoint.findUnique({
where: {
environmentId_slug: {
environmentId,
slug: endpointSlug,
},
},
},
include: {
environment: true,
},
});
include: {
environment: true,
},
});
if (!endpoint) {
throw new Error("Endpoint not found");
}
if (endpoint.indexingHookIdentifier !== indexHookIdentifier) {
throw new Error("Index hook identifier is invalid");
}
const reason = parseReasonFromBody(body);
// Index the endpoint in 5 seconds from now
await workerQueue.enqueue(
"indexEndpoint",
{
id: endpoint.id,
source: "HOOK",
reason,
sourceData: body,
},
{
runAt: new Date(Date.now() + 5000),
maxAttempts:
endpoint.environment.type === RuntimeEnvironmentType.DEVELOPMENT ? 1 : undefined,
if (!endpoint) {
throw new Error("Endpoint not found");
}
);
if (endpoint.indexingHookIdentifier !== indexHookIdentifier) {
throw new Error("Index hook identifier is invalid");
}
const reason = parseReasonFromBody(body);
const index = await tx.endpointIndex.create({
data: {
endpointId: endpoint.id,
status: "PENDING",
source: "HOOK",
reason,
sourceData: body,
},
});
// Index the endpoint in 5 seconds from now
await workerQueue.enqueue(
"performEndpointIndexing",
{
id: index.id,
},
{
runAt: new Date(Date.now() + 5000),
maxAttempts:
endpoint.environment.type === RuntimeEnvironmentType.DEVELOPMENT ? 1 : undefined,
tx,
}
);
});
}
}
@@ -1,11 +1,5 @@
import { parse } from "@conform-to/zod";
import { ActionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import {
CreateEndpointError,
CreateEndpointService,
} from "~/services/endpoints/createEndpoint.server";
import { IndexEndpointService } from "~/services/endpoints/indexEndpoint.server";
import { requireUserId } from "~/services/session.server";
+7 -33
View File
@@ -104,6 +104,7 @@ export class EndpointApi {
}
async indexEndpoint() {
const startTimeInMs = performance.now();
const response = await safeFetch(this.url, {
method: "POST",
headers: {
@@ -113,40 +114,13 @@ export class EndpointApi {
},
});
if (!response) {
throw new Error(`Could not connect to endpoint ${this.url}`);
}
if (response.status === 401) {
const body = await safeBodyFromResponse(response, ErrorWithStackSchema);
if (body) {
return {
ok: false,
error: body.message,
} as const;
}
return {
ok: false,
error: `Trigger API key is invalid`,
} as const;
}
if (!response.ok) {
throw new Error(`Could not connect to endpoint ${this.url}. Status code: ${response.status}`);
}
const anyBody = await response.json();
const data = IndexEndpointResponseSchema.parse(anyBody);
const headers = EndpointHeadersSchema.parse(Object.fromEntries(response.headers.entries()));
return {
ok: true,
data,
headers,
} as const;
response,
headerParser: EndpointHeadersSchema,
parser: IndexEndpointResponseSchema,
errorParser: ErrorWithStackSchema,
durationInMs: Math.floor(performance.now() - startTimeInMs),
};
}
async executeJobRequest(options: RunJobBody) {
@@ -82,12 +82,19 @@ export class CreateEndpointService {
},
});
const endpointIndex = await tx.endpointIndex.create({
data: {
endpointId: endpoint.id,
status: "PENDING",
source: "INTERNAL",
},
});
// Kick off process to fetch the jobs for this endpoint
await workerQueue.enqueue(
"indexEndpoint",
"performEndpointIndexing",
{
id: endpoint.id,
source: "INTERNAL",
id: endpointIndex.id,
},
{
tx,
@@ -96,7 +103,7 @@ export class CreateEndpointService {
}
);
return endpoint;
return { ...endpoint, endpointIndex };
});
return result;
@@ -1,23 +1,9 @@
import type { EndpointIndexSource } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { findEndpoint } from "~/models/endpoint.server";
import { EndpointApi } from "../endpointApi.server";
import { RegisterJobService } from "../jobs/registerJob.server";
import { logger } from "../logger.server";
import { RegisterSourceServiceV1 } from "../sources/registerSourceV1.server";
import { RegisterDynamicScheduleService } from "../triggers/registerDynamicSchedule.server";
import { RegisterDynamicTriggerService } from "../triggers/registerDynamicTrigger.server";
import { DisableJobService } from "../jobs/disableJob.server";
import { RegisterSourceServiceV2 } from "../sources/registerSourceV2.server";
import { PerformEndpointIndexService } from "./performEndpointIndexService";
export class IndexEndpointService {
#prismaClient: PrismaClient;
#registerJobService = new RegisterJobService();
#disableJobService = new DisableJobService();
#registerSourceServiceV1 = new RegisterSourceServiceV1();
#registerSourceServiceV2 = new RegisterSourceServiceV2();
#registerDynamicTriggerService = new RegisterDynamicTriggerService();
#registerDynamicScheduleService = new RegisterDynamicScheduleService();
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
@@ -29,220 +15,17 @@ export class IndexEndpointService {
reason?: string,
sourceData?: any
) {
const endpoint = await findEndpoint(id);
// Make a request to the endpoint to fetch a list of jobs
const client = new EndpointApi(endpoint.environment.apiKey, endpoint.url);
const indexResponse = await client.indexEndpoint();
if (!indexResponse.ok) {
throw new Error(indexResponse.error);
}
const { jobs, sources, dynamicTriggers, dynamicSchedules } = indexResponse.data;
const { "trigger-version": triggerVersion } = indexResponse.headers;
logger.debug("Indexing endpoint", {
endpointId: endpoint.id,
endpointUrl: endpoint.url,
endpointSlug: endpoint.slug,
source: source,
sourceData: sourceData,
triggerVersion,
stats: {
jobs: jobs.length,
sources: sources.length,
dynamicTriggers: dynamicTriggers.length,
dynamicSchedules: dynamicSchedules.length,
},
});
if (triggerVersion && triggerVersion !== endpoint.version) {
await this.#prismaClient.endpoint.update({
where: {
id: endpoint.id,
},
data: {
version: triggerVersion,
},
});
}
const indexStats = {
jobs: 0,
sources: 0,
dynamicTriggers: 0,
dynamicSchedules: 0,
disabledJobs: 0,
};
const existingJobs = await this.#prismaClient.job.findMany({
where: {
projectId: endpoint.projectId,
deletedAt: null,
},
include: {
aliases: {
where: {
name: "latest",
environmentId: endpoint.environmentId,
},
include: {
version: true,
},
take: 1,
},
},
});
for (const job of jobs) {
if (!job.enabled) {
const disabledJob = await this.#disableJobService
.call(endpoint, { slug: job.id, version: job.version })
.catch((error) => {
logger.error("Failed to disable job", {
endpointId: endpoint.id,
job,
error,
});
return;
});
if (disabledJob) {
indexStats.disabledJobs++;
}
} else {
try {
const registeredVersion = await this.#registerJobService.call(endpoint, job);
if (registeredVersion) {
indexStats.jobs++;
}
} catch (error) {
logger.error("Failed to register job", {
endpointId: endpoint.id,
job,
error,
});
}
}
}
// TODO: we need to do this for sources, dynamic triggers, and dynamic schedules
const missingJobs = existingJobs.filter((job) => {
return !jobs.find((j) => j.id === job.slug);
});
if (missingJobs.length > 0) {
logger.debug("Disabling missing jobs", {
endpointId: endpoint.id,
missingJobIds: missingJobs.map((job) => job.slug),
});
for (const job of missingJobs) {
const latestVersion = job.aliases[0]?.version;
if (!latestVersion) {
continue;
}
const disabledJob = await this.#disableJobService
.call(endpoint, {
slug: job.slug,
version: latestVersion.version,
})
.catch((error) => {
logger.error("Failed to disable job", {
endpointId: endpoint.id,
job,
error,
});
return;
});
if (disabledJob) {
indexStats.disabledJobs++;
}
}
}
for (const source of sources) {
try {
switch (source.version) {
default:
case "1": {
await this.#registerSourceServiceV1.call(endpoint, source);
break;
}
case "2": {
await this.#registerSourceServiceV2.call(endpoint, source);
break;
}
}
indexStats.sources++;
} catch (error) {
logger.error("Failed to register source", {
endpointId: endpoint.id,
source,
error,
});
}
}
for (const dynamicTrigger of dynamicTriggers) {
try {
await this.#registerDynamicTriggerService.call(endpoint, dynamicTrigger);
indexStats.dynamicTriggers++;
} catch (error) {
logger.error("Failed to register dynamic trigger", {
endpointId: endpoint.id,
dynamicTrigger,
error,
});
}
}
for (const dynamicSchedule of dynamicSchedules) {
try {
await this.#registerDynamicScheduleService.call(endpoint, dynamicSchedule);
indexStats.dynamicSchedules++;
} catch (error) {
logger.error("Failed to register dynamic schedule", {
endpointId: endpoint.id,
dynamicSchedule,
error,
});
}
}
logger.debug("Endpoint indexing complete", {
endpointId: endpoint.id,
indexStats,
source,
sourceData,
reason,
});
return await this.#prismaClient.endpointIndex.create({
const endpointIndex = await this.#prismaClient.endpointIndex.create({
data: {
endpointId: endpoint.id,
stats: indexStats,
data: {
jobs,
sources,
dynamicTriggers,
dynamicSchedules,
},
endpointId: id,
status: "PENDING",
source,
sourceData,
reason,
sourceData,
},
});
const performEndpointIndexService = new PerformEndpointIndexService();
return await performEndpointIndexService.call(endpointIndex.id);
}
}
@@ -0,0 +1,338 @@
import type { EndpointIndexSource } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { findEndpoint } from "~/models/endpoint.server";
import { EndpointApi } from "../endpointApi.server";
import { RegisterJobService } from "../jobs/registerJob.server";
import { logger } from "../logger.server";
import { RegisterSourceServiceV1 } from "../sources/registerSourceV1.server";
import { RegisterDynamicScheduleService } from "../triggers/registerDynamicSchedule.server";
import { RegisterDynamicTriggerService } from "../triggers/registerDynamicTrigger.server";
import { DisableJobService } from "../jobs/disableJob.server";
import { RegisterSourceServiceV2 } from "../sources/registerSourceV2.server";
import { EndpointIndexError } from "@trigger.dev/core";
import { safeBodyFromResponse } from "~/utils/json";
import { fromZodError } from "zod-validation-error";
import { IndexEndpointStats } from "@trigger.dev/core";
export class PerformEndpointIndexService {
#prismaClient: PrismaClient;
#registerJobService = new RegisterJobService();
#disableJobService = new DisableJobService();
#registerSourceServiceV1 = new RegisterSourceServiceV1();
#registerSourceServiceV2 = new RegisterSourceServiceV2();
#registerDynamicTriggerService = new RegisterDynamicTriggerService();
#registerDynamicScheduleService = new RegisterDynamicScheduleService();
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(id: string) {
const endpointIndex = await this.#prismaClient.endpointIndex.update({
where: {
id,
},
data: {
status: "STARTED",
},
include: {
endpoint: {
include: {
environment: {
include: {
organization: true,
project: true,
},
},
},
},
},
});
logger.debug("Performing endpoint index", endpointIndex);
// Make a request to the endpoint to fetch a list of jobs
const client = new EndpointApi(
endpointIndex.endpoint.environment.apiKey,
endpointIndex.endpoint.url
);
const { response, parser, headerParser, errorParser } = await client.indexEndpoint();
if (!response) {
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: `Could not connect to endpoint ${endpointIndex.endpoint.url}`,
});
}
if (response.status === 401) {
const body = await safeBodyFromResponse(response, errorParser);
if (body) {
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: body.message,
});
}
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: "Trigger API key is invalid",
});
}
if (!response.ok) {
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: `Could not connect to endpoint ${endpointIndex.endpoint.url}. Status code: ${response.status}`,
});
}
const anyBody = await response.json();
const bodyResult = parser.safeParse(anyBody);
if (!bodyResult.success) {
const issues: string[] = [];
bodyResult.error.issues.forEach((issue) => {
if (issue.path.at(0) === "jobs") {
const jobIndex = issue.path.at(1) as number;
const job = (anyBody as any).jobs[jobIndex];
if (job) {
issues.push(`Job "${job.id}": ${issue.message} at "${issue.path.slice(2).join(".")}".`);
}
}
});
let friendlyError: string | undefined;
if (issues.length > 0) {
friendlyError = `Your Jobs have issues:\n${issues.map((issue) => `- ${issue}`).join("\n")}`;
} else {
friendlyError = fromZodError(bodyResult.error, {
prefix: "There's an issue with the format of your Jobs",
}).message;
}
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: friendlyError,
raw: bodyResult.error.issues,
});
}
const headerResult = headerParser.safeParse(Object.fromEntries(response.headers.entries()));
if (!headerResult.success) {
const friendlyError = fromZodError(headerResult.error, {
prefix: "Your headers are invalid",
});
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: friendlyError.message,
raw: headerResult.error.issues,
});
}
const { jobs, sources, dynamicTriggers, dynamicSchedules } = bodyResult.data;
const { "trigger-version": triggerVersion } = headerResult.data;
const { endpoint } = endpointIndex;
if (triggerVersion && triggerVersion !== endpoint.version) {
await this.#prismaClient.endpoint.update({
where: {
id: endpoint.id,
},
data: {
version: triggerVersion,
},
});
}
const indexStats: IndexEndpointStats = {
jobs: 0,
sources: 0,
dynamicTriggers: 0,
dynamicSchedules: 0,
disabledJobs: 0,
};
const existingJobs = await this.#prismaClient.job.findMany({
where: {
projectId: endpoint.projectId,
deletedAt: null,
},
include: {
aliases: {
where: {
name: "latest",
environmentId: endpoint.environmentId,
},
include: {
version: true,
},
take: 1,
},
},
});
for (const job of jobs) {
if (!job.enabled) {
const disabledJob = await this.#disableJobService
.call(endpoint, { slug: job.id, version: job.version })
.catch((error) => {
logger.error("Failed to disable job", {
endpointId: endpoint.id,
job,
error,
});
return;
});
if (disabledJob) {
indexStats.disabledJobs++;
}
} else {
try {
const registeredVersion = await this.#registerJobService.call(endpoint, job);
if (registeredVersion) {
if (!job.internal) {
indexStats.jobs++;
}
}
} catch (error) {
logger.error("Failed to register job", {
endpointId: endpoint.id,
job,
error,
});
}
}
}
// TODO: we need to do this for sources, dynamic triggers, and dynamic schedules
const missingJobs = existingJobs.filter((job) => {
return !jobs.find((j) => j.id === job.slug);
});
if (missingJobs.length > 0) {
logger.debug("Disabling missing jobs", {
endpointId: endpoint.id,
missingJobIds: missingJobs.map((job) => job.slug),
});
for (const job of missingJobs) {
const latestVersion = job.aliases[0]?.version;
if (!latestVersion) {
continue;
}
const disabledJob = await this.#disableJobService
.call(endpoint, {
slug: job.slug,
version: latestVersion.version,
})
.catch((error) => {
logger.error("Failed to disable job", {
endpointId: endpoint.id,
job,
error,
});
return;
});
if (disabledJob) {
indexStats.disabledJobs++;
}
}
}
for (const source of sources) {
try {
switch (source.version) {
default:
case "1": {
await this.#registerSourceServiceV1.call(endpoint, source);
break;
}
case "2": {
await this.#registerSourceServiceV2.call(endpoint, source);
break;
}
}
indexStats.sources++;
} catch (error) {
logger.error("Failed to register source", {
endpointId: endpoint.id,
source,
error,
});
}
}
for (const dynamicTrigger of dynamicTriggers) {
try {
await this.#registerDynamicTriggerService.call(endpoint, dynamicTrigger);
indexStats.dynamicTriggers++;
} catch (error) {
logger.error("Failed to register dynamic trigger", {
endpointId: endpoint.id,
dynamicTrigger,
error,
});
}
}
for (const dynamicSchedule of dynamicSchedules) {
try {
await this.#registerDynamicScheduleService.call(endpoint, dynamicSchedule);
indexStats.dynamicSchedules++;
} catch (error) {
logger.error("Failed to register dynamic schedule", {
endpointId: endpoint.id,
dynamicSchedule,
error,
});
}
}
logger.debug("Endpoint indexing complete", {
endpointId: endpoint.id,
indexStats,
source: endpointIndex.source,
sourceData: endpointIndex.sourceData,
reason: endpointIndex.reason,
});
return await this.#prismaClient.endpointIndex.update({
where: {
id,
},
data: {
status: "SUCCESS",
stats: indexStats,
data: {
jobs,
sources,
dynamicTriggers,
dynamicSchedules,
},
},
});
}
}
async function updateEndpointIndexWithError(
prismaClient: PrismaClient,
id: string,
error: EndpointIndexError
) {
return await prismaClient.endpointIndex.update({
where: {
id,
},
data: {
status: "FAILURE",
error,
},
});
}
@@ -17,7 +17,9 @@ export class RecurringEndpointIndexService {
const endpoints = await this.#prismaClient.endpoint.findMany({
where: {
environment: {
type: RuntimeEnvironmentType.PRODUCTION,
type: {
in: [RuntimeEnvironmentType.PRODUCTION, RuntimeEnvironmentType.STAGING],
},
},
indexings: {
none: {
@@ -32,12 +34,18 @@ export class RecurringEndpointIndexService {
logger.debug("Found endpoints that haven't been indexed in the last 10 minutes", {
count: endpoints.length,
});
// Enqueue each endpoint for indexing
for (const endpoint of endpoints) {
await workerQueue.enqueue("indexEndpoint", {
id: endpoint.id,
source: "INTERNAL",
const index = await this.#prismaClient.endpointIndex.create({
data: {
endpointId: endpoint.id,
status: "PENDING",
source: "INTERNAL",
},
});
await workerQueue.enqueue("performEndpointIndexing", {
id: index.id,
});
}
}
@@ -66,12 +66,15 @@ export class ValidateCreateEndpointService {
},
});
// Kick off process to fetch the jobs for this endpoint
const index = await tx.endpointIndex.create({
data: { endpointId: endpoint.id, status: "PENDING", source: "INTERNAL" },
});
// Kick off process to fetch the jobs for this index
await workerQueue.enqueue(
"indexEndpoint",
"performEndpointIndexing",
{
id: endpoint.id,
source: "INTERNAL",
id: index.id,
},
{
tx,
+15 -5
View File
@@ -1,16 +1,18 @@
import { DeliverEmailSchema } from "@/../../packages/emails/src";
import { ScheduledPayloadSchema } from "@trigger.dev/core";
import { ScheduledPayloadSchema, addMissingVersionField } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { ZodWorker } from "~/platform/zodWorker.server";
import { sendEmail, sendPlainTextEmail } from "./email.server";
import { sendEmail } from "./email.server";
import { IndexEndpointService } from "./endpoints/indexEndpoint.server";
import { PerformEndpointIndexService } from "./endpoints/performEndpointIndexService";
import { RecurringEndpointIndexService } from "./endpoints/recurringEndpointIndex.server";
import { DeliverEventService } from "./events/deliverEvent.server";
import { InvokeDispatcherService } from "./events/invokeDispatcher.server";
import { integrationAuthRepository } from "./externalApis/integrationAuthRepository.server";
import { IntegrationConnectionCreatedService } from "./externalApis/integrationConnectionCreated.server";
import { logger } from "./logger.server";
import { MissingConnectionCreatedService } from "./runs/missingConnectionCreated.server";
import { PerformRunExecutionV1Service } from "./runs/performRunExecutionV1.server";
import { PerformRunExecutionV2Service } from "./runs/performRunExecutionV2.server";
@@ -20,8 +22,6 @@ import { ActivateSourceService } from "./sources/activateSource.server";
import { DeliverHttpSourceRequestService } from "./sources/deliverHttpSourceRequest.server";
import { PerformTaskOperationService } from "./tasks/performTaskOperation.server";
import { ProcessCallbackTimeoutService } from "./tasks/processCallbackTimeout";
import { addMissingVersionField } from "@trigger.dev/core";
import { logger } from "./logger.server";
const workerCatalog = {
indexEndpoint: z.object({
@@ -30,6 +30,9 @@ const workerCatalog = {
sourceData: z.any().optional(),
reason: z.string().optional(),
}),
performEndpointIndexing: z.object({
id: z.string(),
}),
scheduleEmail: DeliverEmailSchema,
startRun: z.object({ id: z.string() }),
processCallbackTimeout: z.object({
@@ -284,10 +287,17 @@ function getWorkerQueue() {
maxAttempts: 7,
handler: async (payload, job) => {
const service = new IndexEndpointService();
await service.call(payload.id, payload.source, payload.reason, payload.sourceData);
},
},
performEndpointIndexing: {
priority: 1, // smaller number = higher priority
maxAttempts: 7,
handler: async (payload, job) => {
const service = new PerformEndpointIndexService();
await service.call(payload.id);
},
},
deliverEvent: {
priority: 0, // smaller number = higher priority
maxAttempts: 5,
+2 -1
View File
@@ -113,7 +113,8 @@
"tiny-invariant": "^1.2.0",
"ulid": "^2.3.0",
"zod": "3.22.3",
"zod-error": "1.5.0"
"zod-error": "1.5.0",
"zod-validation-error": "^1.5.0"
},
"devDependencies": {
"@remix-run/dev": "1.19.2-pre.0",
+3
View File
@@ -56,11 +56,13 @@
"test": "vitest"
},
"dependencies": {
"@trigger.dev/core": "workspace:*",
"@types/degit": "^2.8.3",
"boxen": "^7.1.1",
"chalk": "^5.2.0",
"chokidar": "^3.5.3",
"commander": "^9.4.1",
"console-table-printer": "^2.11.2",
"degit": "^2.8.4",
"dotenv": "^16.3.1",
"execa": "^7.0.0",
@@ -74,6 +76,7 @@
"npm-check-updates": "^16.12.2",
"openai": "^4.5.0",
"ora": "^6.1.2",
"p-retry": "^6.1.0",
"path-to-regexp": "^6.2.1",
"posthog-node": "^3.1.1",
"proxy-agent": "^6.3.0",
+175 -104
View File
@@ -1,3 +1,4 @@
import boxen from "boxen";
import chalk from "chalk";
import childProcess from "child_process";
import chokidar from "chokidar";
@@ -5,10 +6,12 @@ import fs from "fs/promises";
import ngrok from "ngrok";
import { run as ncuRun } from "npm-check-updates";
import ora, { Ora } from "ora";
import pRetry, { AbortError } from "p-retry";
import pathModule from "path";
import util from "util";
import { z } from "zod";
import { Framework, getFramework } from "../frameworks";
import { standardWatchFilePaths, standardWatchIgnoreRegex } from "../frameworks/watchConfig";
import { telemetryClient } from "../telemetry/telemetry";
import { getEnvFilename } from "../utils/env";
import fetch from "../utils/fetchUseProxy";
@@ -17,8 +20,9 @@ import { getUserPackageManager } from "../utils/getUserPkgManager";
import { logger } from "../utils/logger";
import { resolvePath } from "../utils/parseNameAndPath";
import { RequireKeys } from "../utils/requiredKeys";
import { Throttle } from "../utils/throttle";
import { TriggerApi } from "../utils/triggerApi";
import { standardWatchIgnoreRegex, standardWatchFilePaths } from "../frameworks/watchConfig";
import { wait } from "../utils/wait";
const asyncExecFile = util.promisify(childProcess.execFile);
@@ -86,7 +90,7 @@ export async function devCommand(path: string, anyOptions: any) {
const verifiedEndpoint = await verifyEndpoint(resolvedOptions, endpointId, apiKey, framework);
if (!verifiedEndpoint) {
logger.error(
`✖ [trigger.dev] Failed to find a valid Trigger.dev endpoint. Make sure your app is running and try again.`
`✖ [trigger.dev] Your endpoint couldn't be verified. Make sure your app is running and try again. ${resolvedOptions.handlerPath}`
);
logger.info(` [trigger.dev] You can use -H to specify a hostname, or -p to specify a port.`);
telemetryClient.dev.failed("no_server_found", resolvedOptions);
@@ -107,83 +111,6 @@ export async function devCommand(path: string, anyOptions: any) {
const endpointHandlerUrl = `${endpointUrl}${handlerPath}`;
telemetryClient.dev.tunnelRunning(path, resolvedOptions);
const connectingSpinner = ora(`[trigger.dev] Registering endpoint ${endpointHandlerUrl}...`);
//refresh function
let hasConnected = false;
let attemptCount = 0;
const refresh = async () => {
connectingSpinner.start();
const refreshedEndpointId = await getEndpointIdFromPackageJson(resolvedPath, resolvedOptions);
// Read from env file to get the TRIGGER_API_KEY and TRIGGER_API_URL
const apiDetails = await getTriggerApiDetails(resolvedPath, resolvedOptions.envFile);
if (!apiDetails) {
connectingSpinner.fail(`[trigger.dev] Failed to connect: Missing API Key`);
logger.info(`Will attempt again on the next file change…`);
attemptCount = 0;
return;
}
const { apiKey, apiUrl } = apiDetails;
const apiClient = new TriggerApi(apiKey, apiUrl);
const authorizedKey = await apiClient.whoami(apiKey);
if (!authorizedKey) {
logger.error(
`✖ [trigger.dev] The API key you provided is not authorized. Try visiting your dashboard to get a new API key.`
);
telemetryClient.dev.failed("invalid_api_key", resolvedOptions);
return;
}
telemetryClient.identify(
authorizedKey.organization.id,
authorizedKey.project.id,
authorizedKey.userId
);
const result = await refreshEndpoint(
apiClient,
refreshedEndpointId ?? endpointId,
endpointHandlerUrl
);
if (result.success) {
attemptCount = 0;
connectingSpinner.succeed(
`[trigger.dev] 🔄 Refreshed ${refreshedEndpointId ?? endpointId} ${formattedDate.format(
new Date(result.data.updatedAt)
)}`
);
if (!hasConnected) {
hasConnected = true;
telemetryClient.dev.connected(path, resolvedOptions);
}
} else {
attemptCount++;
if (attemptCount === 10 || !result.retryable) {
connectingSpinner.fail(`Failed to connect: ${result.error}`);
logger.info(`Will attempt again on the next file change…`);
attemptCount = 0;
if (!hasConnected) {
telemetryClient.dev.failed("failed_to_connect", resolvedOptions);
}
return;
}
const delay = backoff(attemptCount);
// console.log(`Attempt: ${attemptCount}`, delay);
await wait(delay);
refresh();
}
};
// Watch for changes to files and refresh endpoints
const watchPaths = (framework?.watchFilePaths ?? standardWatchFilePaths).map(
(path) => `${resolvedPath}/${path}`
@@ -195,12 +122,178 @@ export async function devCommand(path: string, anyOptions: any) {
ignoreInitial: true,
});
const connectingSpinner = ora(`[trigger.dev] Registering endpoint ${endpointHandlerUrl}...`);
let hasConnected = false;
const abortController = new AbortController();
const r = () => {
refresh({
endpointId,
spinner: connectingSpinner,
path: resolvedPath,
endpointHandlerUrl,
resolvedOptions,
hasConnected,
abortController,
});
};
const throttle = new Throttle(r, throttleTimeMs);
watcher.on("all", (_event, _path) => {
throttle(refresh, throttleTimeMs);
throttle.call();
});
//Do initial refresh
throttle(refresh, throttleTimeMs);
throttle.call();
}
type RefreshOptions = {
spinner: Ora;
path: string;
endpointId: string;
endpointHandlerUrl: string;
resolvedOptions: ResolvedOptions;
hasConnected: boolean;
abortController: AbortController;
};
async function refresh(options: RefreshOptions) {
//stop any existing refreshes
options.abortController.abort();
options.abortController = new AbortController();
// Read from env file to get the TRIGGER_API_KEY and TRIGGER_API_URL
const apiDetails = await getTriggerApiDetails(options.path, options.resolvedOptions.envFile);
if (!apiDetails) {
options.spinner.fail("[trigger.dev] Failed to connect: Missing API Key");
return;
}
const { apiKey, apiUrl } = apiDetails;
const apiClient = new TriggerApi(apiKey, apiUrl);
try {
const index = await pRetry(() => startIndexing({ ...options, apiClient }), {
retries: 5,
signal: options.abortController.signal,
maxTimeout: 5000,
});
options.spinner.text = `[trigger.dev] Refreshing ${formattedDate.format(index.updatedAt)}`;
if (!options.hasConnected) {
options.hasConnected = true;
telemetryClient.dev.connected(options.path, options.resolvedOptions);
}
//this is for backwards-compatibility with older servers
if (index.id === undefined) {
options.spinner.succeed(`[trigger.dev] Refreshed ${formattedDate.format(index.updatedAt)}`);
return;
}
//wait 750ms before attempting to get the indexing result
await wait(750);
const indexResult = await pRetry(() => fetchIndexResult({ indexId: index.id, apiClient }), {
//this means we're polling, same distance between each attempt
factor: 1,
retries: 10,
signal: options.abortController.signal,
});
if (indexResult.status === "FAILURE") {
options.spinner.fail(
`[trigger.dev] Refreshing failed ${formattedDate.format(indexResult.updatedAt)}`
);
logger.error(
boxen(indexResult.error.message, {
padding: 1,
borderStyle: "double",
})
);
return;
}
options.spinner.succeed(
`[trigger.dev] Refreshed ${formattedDate.format(indexResult.updatedAt)}`
);
} catch (e) {
if (e instanceof AbortError) {
options.spinner.fail(e.message);
logger.info(` [trigger.dev] Will attempt again on the next file change…`);
return;
}
let message: string = "";
if (e instanceof Error) {
message = e.message;
} else {
message = "Unknown error";
}
options.spinner.fail(message);
logger.info(` [trigger.dev] Will attempt again on the next file change…`);
if (!options.hasConnected) {
telemetryClient.dev.failed("failed_to_connect", options.resolvedOptions);
}
}
}
async function startIndexing({
spinner,
path,
endpointId,
endpointHandlerUrl,
resolvedOptions,
apiClient,
}: RefreshOptions & { apiClient: TriggerApi }) {
spinner.start();
const refreshedEndpointId = await getEndpointIdFromPackageJson(path, resolvedOptions);
const authorizedKey = await apiClient.whoami();
if (!authorizedKey) {
telemetryClient.dev.failed("invalid_api_key", resolvedOptions);
throw new AbortError(
"[trigger.dev] The API key you provided is not authorized. Try visiting your dashboard to get a new API key."
);
}
telemetryClient.identify(
authorizedKey.organization.id,
authorizedKey.project.id,
authorizedKey.userId
);
const result = await refreshEndpoint(
apiClient,
refreshedEndpointId ?? endpointId,
endpointHandlerUrl
);
if (!result.success) {
throw new Error(result.error);
}
return { id: result.data.endpointIndex?.id, updatedAt: new Date(result.data.updatedAt) };
}
async function fetchIndexResult({
indexId,
apiClient,
}: {
indexId: string;
apiClient: TriggerApi;
}) {
const result = await apiClient.getEndpointIndex(indexId);
if (result.status === "STARTED" || result.status === "PENDING") {
throw new Error("Indexing is still in progress");
}
return result;
}
async function resolveOptions(
@@ -209,7 +302,7 @@ async function resolveOptions(
unresolvedOptions: DevCommandOptions
): Promise<ResolvedOptions> {
if (!framework) {
logger.info("Failed to detect framework, using default values");
logger.info(" [trigger.dev] Failed to detect framework, using default values");
return {
port: unresolvedOptions.port ?? 3000,
hostname: unresolvedOptions.hostname ?? "localhost",
@@ -431,25 +524,3 @@ async function refreshEndpoint(apiClient: TriggerApi, endpointId: string, endpoi
}
}
}
//wait function
async function wait(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
//throttle function
let throttleTimeout: NodeJS.Timeout | null = null;
function throttle(fn: () => any, delay: number) {
if (throttleTimeout) {
clearTimeout(throttleTimeout);
}
throttleTimeout = setTimeout(fn, delay);
}
const maximum_backoff = 30;
const initial_backoff = 0.2;
function backoff(attempt: number) {
return Math.min((2 ^ attempt) * initial_backoff, maximum_backoff) * 1000;
}
+1 -1
View File
@@ -81,7 +81,7 @@ export const initCommand = async (options: InitCommandOptions) => {
}
const apiClient = new TriggerApi(apiKey, optionsAfterPrompts.apiUrl);
const authorizedKey = await apiClient.whoami(apiKey);
const authorizedKey = await apiClient.whoami();
if (!authorizedKey) {
logger.error(
+1 -1
View File
@@ -42,7 +42,7 @@ export async function whoamiCommand(path: string, anyOptions: any) {
}
const triggerAPI = new TriggerApi(apiDetails.apiKey, apiDetails.apiUrl);
const userData = await triggerAPI.whoami(apiDetails.apiKey);
const userData = await triggerAPI.whoami();
loadingSpinner.stop();
+3
View File
@@ -13,4 +13,7 @@ export const logger = {
success(...args: unknown[]) {
console.log(chalk.green(...args));
},
table(rows: any) {
console.table(rows);
},
};
+18
View File
@@ -0,0 +1,18 @@
export class Throttle {
throttleTimeout: NodeJS.Timeout | null = null;
constructor(
private readonly fn: () => any,
private readonly delay: number
) {
this.fn = fn;
this.delay = delay;
}
call() {
if (this.throttleTimeout) {
clearTimeout(this.throttleTimeout);
}
this.throttleTimeout = setTimeout(this.fn, this.delay);
}
}
+34 -2
View File
@@ -1,5 +1,7 @@
import fetch from "./fetchUseProxy";
import { z } from "zod";
import core from "@trigger.dev/core";
const { GetEndpointIndexResponseSchema } = core;
export type CreateEndpointOptions = {
id: string;
@@ -16,6 +18,9 @@ export type EndpointData = {
createdAt: string;
updatedAt: string;
indexingHookIdentifier: string;
endpointIndex: {
id: string;
};
};
export type EndpointResponse =
@@ -59,12 +64,12 @@ export class TriggerApi {
private baseUrl: string
) {}
async whoami(apiKey: string): Promise<WhoamiResponse | undefined> {
async whoami(): Promise<WhoamiResponse | undefined> {
const response = await fetch(`${this.baseUrl}/api/v1/whoami`, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${apiKey}`,
Authorization: `Bearer ${this.apiKey}`,
},
});
@@ -155,6 +160,33 @@ export class TriggerApi {
data: data as any as EndpointData,
};
}
async getEndpointIndex(indexId: string) {
const response = await fetch(`${this.baseUrl}/api/v1/endpointindex/${indexId}`, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
});
if (response.ok) {
const body = await response.json();
const parsed = GetEndpointIndexResponseSchema.safeParse(body);
if (parsed.success) {
return parsed.data;
}
}
return {
status: "FAILURE" as const,
error: {
message: `Bad response from Trigger.dev (${response.status})`,
},
updatedAt: new Date(),
};
}
}
function safeJsonParse(raw: string | null | undefined): unknown {
+5
View File
@@ -0,0 +1,5 @@
export async function wait(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
-1
View File
@@ -9,7 +9,6 @@
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
/* EMIT RULES */
"outDir": "./dist",
"noEmit": true, // TSUP takes care of emitting js for us, in a MUCH faster way
+47
View File
@@ -296,6 +296,53 @@ export const IndexEndpointResponseSchema = z.object({
export type IndexEndpointResponse = z.infer<typeof IndexEndpointResponseSchema>;
export const EndpointIndexErrorSchema = z.object({
message: z.string(),
raw: z.any().optional(),
});
export type EndpointIndexError = z.infer<typeof EndpointIndexErrorSchema>;
const IndexEndpointStatsSchema = z.object({
jobs: z.number(),
sources: z.number(),
dynamicTriggers: z.number(),
dynamicSchedules: z.number(),
disabledJobs: z.number().default(0),
});
export type IndexEndpointStats = z.infer<typeof IndexEndpointStatsSchema>;
export function parseEndpointIndexStats(stats: unknown): IndexEndpointStats | undefined {
if (stats === null || stats === undefined) {
return;
}
return IndexEndpointStatsSchema.parse(stats);
}
export const GetEndpointIndexResponseSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("PENDING"),
updatedAt: z.coerce.date(),
}),
z.object({
status: z.literal("STARTED"),
updatedAt: z.coerce.date(),
}),
z.object({
status: z.literal("SUCCESS"),
stats: IndexEndpointStatsSchema,
updatedAt: z.coerce.date(),
}),
z.object({
status: z.literal("FAILURE"),
error: EndpointIndexErrorSchema,
updatedAt: z.coerce.date(),
}),
]);
export type GetEndpointIndexResponse = z.infer<typeof GetEndpointIndexResponseSchema>;
export const EndpointHeadersSchema = z.object({
"trigger-version": z.string().optional(),
});
+2 -1
View File
@@ -14,8 +14,9 @@
"scripts": {
"generate": "prisma generate",
"db:migrate:dev": "prisma migrate dev",
"db:migrate:dev:create": "prisma migrate dev --create-only",
"db:migrate:deploy": "prisma migrate deploy",
"db:studio": "prisma studio",
"typecheck": "tsc --noEmit"
}
}
}
@@ -0,0 +1,11 @@
-- CreateEnum
CREATE TYPE "EndpointIndexStatus" AS ENUM ('PENDING', 'STARTED', 'SUCCESS', 'FAILURE');
-- AlterTable
ALTER TABLE "EndpointIndex"
ADD COLUMN "status" "EndpointIndexStatus" NOT NULL DEFAULT 'PENDING';
-- Update all existing rows to be SUCCESS. This isn't correct because some of them have failed, but we don't want them to be PENDING.
UPDATE "EndpointIndex"
SET
"status" = 'SUCCESS';
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "EndpointIndex" ALTER COLUMN "data" DROP NOT NULL,
ALTER COLUMN "stats" DROP NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EndpointIndex" ADD COLUMN "error" JSONB;
+13 -4
View File
@@ -391,9 +391,11 @@ model EndpointIndex {
source EndpointIndexSource @default(MANUAL)
sourceData Json?
reason String?
status EndpointIndexStatus @default(PENDING)
data Json
stats Json
data Json?
stats Json?
error Json?
}
enum EndpointIndexSource {
@@ -403,6 +405,13 @@ enum EndpointIndexSource {
HOOK
}
enum EndpointIndexStatus {
PENDING
STARTED
SUCCESS
FAILURE
}
model Job {
id String @id @default(cuid())
slug String
@@ -664,8 +673,8 @@ model EventRecord {
}
model JobRun {
id String @id @default(cuid())
number Int
id String @id @default(cuid())
number Int
internal Boolean @default(false)
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+46 -13
View File
@@ -198,6 +198,7 @@ importers:
ulid: ^2.3.0
zod: 3.22.3
zod-error: 1.5.0
zod-validation-error: ^1.5.0
dependencies:
'@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu
'@codemirror/commands': 6.1.3
@@ -284,6 +285,7 @@ importers:
ulid: 2.3.0
zod: 3.22.3
zod-error: 1.5.0
zod-validation-error: 1.5.0_zod@3.22.3
devDependencies:
'@remix-run/dev': 1.19.2-pre.0_36n2i74sizt32vwpdxc4husnkq
'@remix-run/eslint-config': 1.19.2-pre.0_ol4nhuzbuflsbzk2mijpqykzba
@@ -659,6 +661,7 @@ importers:
packages/cli:
specifiers:
'@trigger.dev/core': workspace:*
'@trigger.dev/tsconfig': workspace:*
'@types/degit': ^2.8.3
'@types/gradient-string': ^1.1.2
@@ -670,6 +673,7 @@ importers:
chalk: ^5.2.0
chokidar: ^3.5.3
commander: ^9.4.1
console-table-printer: ^2.11.2
degit: ^2.8.4
dotenv: ^16.3.1
execa: ^7.0.0
@@ -683,6 +687,7 @@ importers:
npm-check-updates: ^16.12.2
openai: ^4.5.0
ora: ^6.1.2
p-retry: ^6.1.0
path-to-regexp: ^6.2.1
posthog-node: ^3.1.1
proxy-agent: ^6.3.0
@@ -697,11 +702,13 @@ importers:
vitest: ^0.34.4
zod: 3.22.3
dependencies:
'@trigger.dev/core': link:../core
'@types/degit': 2.8.3
boxen: 7.1.1
chalk: 5.2.0
chokidar: 3.5.3
commander: 9.5.0
console-table-printer: 2.11.2
degit: 2.8.4
dotenv: 16.3.1
execa: 7.0.0
@@ -715,6 +722,7 @@ importers:
npm-check-updates: 16.12.3
openai: 4.5.0
ora: 6.1.2
p-retry: 6.1.0
path-to-regexp: 6.2.1
posthog-node: 3.1.1
proxy-agent: 6.3.0
@@ -13840,6 +13848,10 @@ packages:
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
dev: false
/@types/retry/0.12.2:
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
dev: false
/@types/scheduler/0.16.2:
resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
@@ -15614,7 +15626,7 @@ packages:
/axios/0.21.4_debug@4.3.2:
resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==}
dependencies:
follow-redirects: 1.15.2_debug@4.3.2
follow-redirects: 1.15.2
transitivePeerDependencies:
- debug
dev: false
@@ -16972,6 +16984,12 @@ packages:
/console-control-strings/1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
/console-table-printer/2.11.2:
resolution: {integrity: sha512-uuUHie0sfPP542TKGzPFal0W1wo1beuKAqIZdaavcONx8OoqdnJRKjkinbRTOta4FaCa1RcIL+7mMJWX3pQGVg==}
dependencies:
simple-wcswidth: 1.0.1
dev: false
/content-disposition/0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -20411,18 +20429,6 @@ packages:
debug:
optional: true
/follow-redirects/1.15.2_debug@4.3.2:
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
dependencies:
debug: 4.3.2
dev: false
/for-each/0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
dependencies:
@@ -22174,6 +22180,11 @@ packages:
resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==}
engines: {node: '>= 0.4'}
/is-network-error/1.0.0:
resolution: {integrity: sha512-P3fxi10Aji2FZmHTrMPSNFbNC6nnp4U5juPAIjXPHkUNubi4+qK7vvdsaNpAUwXslhYm9oyjEYTxs1xd/+Ph0w==}
engines: {node: '>=16'}
dev: false
/is-node-process/1.0.1:
resolution: {integrity: sha512-5IcdXuf++TTNt3oGl9EBdkvndXA8gmc4bz/Y+mdEpWh3Mcn/+kOw6hI7LD5CocqJWMzeb0I0ClndRVNdEPuJXQ==}
dev: true
@@ -25802,6 +25813,15 @@ packages:
retry: 0.13.1
dev: false
/p-retry/6.1.0:
resolution: {integrity: sha512-fJLEQ2KqYBJRuaA/8cKMnqhulqNM+bpcjYtXNex2t3mOXKRYPitAJt9NacSf8XAFzcYahSAbKpobiWDSqHSh2g==}
engines: {node: '>=16.17'}
dependencies:
'@types/retry': 0.12.2
is-network-error: 1.0.0
retry: 0.13.1
dev: false
/p-timeout/3.2.0:
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
engines: {node: '>=8'}
@@ -28780,6 +28800,10 @@ packages:
semver: 7.5.4
dev: true
/simple-wcswidth/1.0.1:
resolution: {integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==}
dev: false
/simplur/3.0.1:
resolution: {integrity: sha512-bBAoTn75tuKh83opmZ1VoyVoQIsvLCKzSxuasAxbnKofrT8eGyOEIaXSuNfhi/hI160+fwsR7ObcbBpOyzDvXg==}
dev: false
@@ -32595,6 +32619,15 @@ packages:
zod: 3.22.3
dev: false
/zod-validation-error/1.5.0_zod@3.22.3:
resolution: {integrity: sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==}
engines: {node: '>=16.0.0'}
peerDependencies:
zod: ^3.18.0
dependencies:
zod: 3.22.3
dev: false
/zod/3.21.1:
resolution: {integrity: sha512-+dTu2m6gmCbO9Ahm4ZBDapx2O6ZY9QSPXst2WXjcznPMwf2YNpn3RevLx4KkZp1OPW/ouFcoBtBzFz/LeY69oA==}
+2 -1
View File
@@ -26,6 +26,7 @@
"byo-auth": "nodemon --watch src/byo-auth.ts -r tsconfig-paths/register -r dotenv/config src/byo-auth.ts",
"redacted": "nodemon --watch src/redacted.ts -r tsconfig-paths/register -r dotenv/config src/redacted.ts",
"replicate": "nodemon --watch src/replicate.ts -r tsconfig-paths/register -r dotenv/config src/replicate.ts",
"misconfigured": "nodemon --watch src/misconfigured.ts -r tsconfig-paths/register -r dotenv/config src/misconfigured.ts",
"dev:trigger": "trigger-cli dev --port 8080"
},
"dependencies": {
@@ -60,4 +61,4 @@
"ts-node": "^10.9.1",
"tsconfig-paths": "^3.14.1"
}
}
}
@@ -0,0 +1,35 @@
import { createExpressServer } from "@trigger.dev/express";
import { TriggerClient, intervalTrigger } from "@trigger.dev/sdk";
export const client = new TriggerClient({
id: "job-catalog",
apiKey: process.env["TRIGGER_API_KEY"],
apiUrl: process.env["TRIGGER_API_URL"],
verbose: false,
ioLogLocalEnabled: true,
});
// This job is misconfigured because the interval trigger is less than 60s
client.defineJob({
id: "bad-interval",
name: "Bad Interval",
version: "0.0.2",
trigger: intervalTrigger({
seconds: 50,
}),
run: async (payload, io, ctx) => {},
});
// This job is misconfigured because it has no name
//@ts-ignore
client.defineJob({
id: "bad-cron",
// name: "Bad CRON expression",
version: "0.0.2",
trigger: intervalTrigger({
seconds: 90,
}),
run: async (payload, io, ctx) => {},
});
createExpressServer(client);