Compare commits

...

31 Commits

Author SHA1 Message Date
Eric Allam 90b8797fbb Release 3.0.0-beta.31 2024-05-13 16:15:00 -04:00
github-actions[bot] 107f2e7bd5 chore: Update version for release (beta) (#1099)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-13 16:14:14 -04:00
Eric Allam b8477ea2b0 Fixes an issue with scoped packages in additionalPackages option 2024-05-13 15:31:19 -04:00
Matt Aitken 864498ea85 Latest lockfile 2024-05-13 17:46:02 +01:00
github-actions[bot] 2e21bc1563 chore: Update version for release (beta) (#1094)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-13 17:38:50 +01:00
Matt Aitken 1477a2e309 Set the timeout for canceling checkpoints 2024-05-13 16:50:53 +01:00
Matt Aitken 2081cb15b8 Removed docs for dashboard as they’re empty at the moment. Moved reference docs for Runs and schedules up 2024-05-12 18:29:46 +01:00
Matt Aitken ff551c5cbd Ticked off alerts (in preparation for putting it live) 2024-05-12 18:27:39 +01:00
Matt Aitken 794b25a988 Some fixes for the v3 docs showing v2 items in the sidebar 2024-05-12 18:27:26 +01:00
Matt Aitken 5fdb43ecd1 Bring alerts side menu item back 2024-05-12 18:05:01 +01:00
Matt Aitken 80997bcd6d .env.example Redis env vars are uncommented 2024-05-12 18:02:42 +01:00
Matt Aitken 194f336b84 Added an image of where to set the v3 GitHub action secret 2024-05-12 18:01:27 +01:00
Eric Allam 2e02743170 Resume rate limiting run executions, but reschedule throttled jobs 10 seconds into the future instead of their original run_at, which will help with database load 2024-05-10 15:44:20 -04:00
nicktrn 1642fd7baf perf: migrate to graphile worker v0.16.6 (#1097)
* migrate to graphile worker v0.16.6

* remove stale docs link

* fix jobs cleanup query
2024-05-10 18:07:16 +01:00
Matt Aitken c6449126b1 Don’t start runs if they have runs disabled 2024-05-10 16:11:06 +01:00
Matt Aitken 1ccf38e440 Return a 404 so the webhook provider doesn’t retry 2024-05-10 16:00:42 +01:00
Matt Aitken 29b773cd36 Prevent ingesting events if the org has runs disabled 2024-05-10 15:59:04 +01:00
Matt Aitken 73fb7cfeb3 Prevent HTTP source requests if the org has runs disabled 2024-05-10 15:58:51 +01:00
nicktrn 5431638926 Revert "Revert "Revert "Revert "disable advisory locks""""
This reverts commit 933a14b44c.
2024-05-10 15:09:35 +01:00
Eric Allam 933a14b44c Revert "Revert "Revert "disable advisory locks"""
This reverts commit 4ff07f8a94.
2024-05-10 09:35:53 -04:00
Eric Allam 4ff07f8a94 Revert "Revert "disable advisory locks""
This reverts commit af9957c085.
2024-05-10 09:30:32 -04:00
Eric Allam 0afdb4cc7c Help out the DB 2024-05-10 09:15:20 -04:00
Eric Allam af9957c085 Revert "disable advisory locks"
This reverts commit 892fd9f212.
2024-05-10 09:12:33 -04:00
Eric Allam 5c4275619b v3.1 experiments 2024-05-10 09:11:54 -04:00
nicktrn 892fd9f212 disable advisory locks 2024-05-10 10:21:48 +01:00
James Ritchie cbe9317a86 Added padding to the email body 2024-05-09 17:39:39 +01:00
James Ritchie 6baf9e5294 Dismiss the dev user tooltip in the task page when your mouse leaves the target 2024-05-09 17:05:17 +01:00
James Ritchie a823421324 Small style improvements to the emails 2024-05-09 16:49:45 +01:00
Eric Allam 0e919f56f2 Alerts v1 (#1065)
* Alerts v1

* Encrypt alert webhook secrets and allow them to be generated by the server

* Alert v1 UI

* Remove unnecessary emails

* Move to using `@react-email/components`

* WIP slack alerts

* More slack alerts WIP

* Update pnpm lock after rebase

* Finish implementing Slack alerts

* Use a more error like emoji

* New secondary variant for the segmented control

* Added a simple checkbox style variant to storybook

* Style tweak to the segmented control

* UI improvements to the alert modal

* Use searchable Select for alerts. Changed default variant for SegmentedControl

* Secondary button now using secondary colour

* segmented control style tweak

* Improved the channel column in the alerts table

* Updated logo-mono.png

* Updated email styles

* Email templates updated to new styles

* Don’t log the decrypted secret

* await enqueing the deployment alert when an index fails

* await enqueing the timeout alert

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-05-09 12:12:00 +01:00
Matt Aitken f90960fc13 Better display when there are lots of dev environment (#1093)
* Created an EnvironmentLabels component that puts extra dev environments behind a +

* Added new environment labels components to schedules pages
2024-05-09 10:32:38 +01:00
nicktrn 2306217697 v3: automatic pod cleanup (#1092)
* add automatic pod cleaner

* fix task monitor namespace override
2024-05-09 10:16:00 +01:00
170 changed files with 7885 additions and 1058 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fixes an issue with scoped packages in additionalPackages option
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Increased the timeout when canceling a checkpoint to 31s (to match the timeout on the server)
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Better handle uncaught exceptions
+3
View File
@@ -44,6 +44,7 @@
"@trigger.dev/yalt": "2.3.18"
},
"changesets": [
"afraid-sheep-joke",
"angry-eagles-trade",
"beige-pens-dance",
"big-tomatoes-deliver",
@@ -68,6 +69,7 @@
"khaki-poems-lay",
"late-icons-lie",
"late-steaks-behave",
"lazy-files-lay",
"lemon-jobs-repair",
"light-bulldogs-press",
"light-dragons-complain",
@@ -80,6 +82,7 @@
"mighty-flowers-train",
"nasty-jars-pump",
"new-rivers-tell",
"nice-bulldogs-turn",
"ninety-pets-travel",
"odd-poets-own",
"pink-pumas-rhyme",
+4 -4
View File
@@ -12,10 +12,10 @@ APP_ENV=development
APP_ORIGIN=http://localhost:3030
NODE_ENV=development
# Redis is used for concurrency control
# REDIS_HOST="localhost"
# REDIS_PORT="6379"
# REDIS_TLS_DISABLED="true"
# Redis is used for the v3 queuing and v2 concurrency control
REDIS_HOST="localhost"
REDIS_PORT="6379"
REDIS_TLS_DISABLED="true"
# OPTIONAL VARIABLES
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
+9
View File
@@ -10,6 +10,7 @@ import {
import { Machine, PostStartCauses, PreStopCauses, EnvironmentType } from "@trigger.dev/core/v3";
import { randomUUID } from "crypto";
import { TaskMonitor } from "./taskMonitor";
import { PodCleaner } from "./podCleaner";
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
const NODE_NAME = process.env.NODE_NAME || "local";
@@ -543,3 +544,11 @@ const taskMonitor = new TaskMonitor({
});
taskMonitor.start();
const podCleaner = new PodCleaner({
runtimeEnv: RUNTIME_ENV,
namespace: "default",
intervalInSeconds: 300,
});
podCleaner.start();
+264
View File
@@ -0,0 +1,264 @@
import * as k8s from "@kubernetes/client-node";
import { SimpleLogger } from "@trigger.dev/core-apps";
type PodCleanerOptions = {
runtimeEnv: "local" | "kubernetes";
namespace?: string;
intervalInSeconds?: number;
};
export class PodCleaner {
private enabled = false;
private namespace = "default";
private intervalInSeconds = 300;
private logger = new SimpleLogger("[PodCleaner]");
private k8sClient: {
core: k8s.CoreV1Api;
kubeConfig: k8s.KubeConfig;
};
constructor(private opts: PodCleanerOptions) {
if (opts.namespace) {
this.namespace = opts.namespace;
}
if (opts.intervalInSeconds) {
this.intervalInSeconds = opts.intervalInSeconds;
}
this.k8sClient = this.#createK8sClient();
}
#createK8sClient() {
const kubeConfig = new k8s.KubeConfig();
if (this.opts.runtimeEnv === "local") {
kubeConfig.loadFromDefault();
} else if (this.opts.runtimeEnv === "kubernetes") {
kubeConfig.loadFromCluster();
} else {
throw new Error(`Unsupported runtime environment: ${this.opts.runtimeEnv}`);
}
return {
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
kubeConfig: kubeConfig,
};
}
#isRecord(candidate: unknown): candidate is Record<string, unknown> {
if (typeof candidate !== "object" || candidate === null) {
return false;
} else {
return true;
}
}
#logK8sError(err: unknown, debugOnly = false) {
if (debugOnly) {
this.logger.debug("K8s API Error", err);
} else {
this.logger.error("K8s API Error", err);
}
}
#handleK8sError(err: unknown) {
if (!this.#isRecord(err) || !this.#isRecord(err.body)) {
this.#logK8sError(err);
return;
}
this.#logK8sError(err, true);
if (typeof err.body.message === "string") {
this.#logK8sError({ message: err.body.message });
return;
}
this.#logK8sError({ body: err.body });
}
async #deletePods(opts: {
namespace: string;
dryRun?: boolean;
fieldSelector?: string;
labelSelector?: string;
}) {
return await this.k8sClient.core
.deleteCollectionNamespacedPod(
opts.namespace,
undefined, // pretty
undefined, // continue
opts.dryRun ? "All" : undefined,
opts.fieldSelector,
undefined, // gracePeriodSeconds
opts.labelSelector
)
.catch(this.#handleK8sError.bind(this));
}
async #deleteCompletedRuns() {
this.logger.log("Deleting completed runs");
const start = Date.now();
const result = await this.#deletePods({
namespace: this.namespace,
fieldSelector: "status.phase=Succeeded",
labelSelector: "app=task-run",
});
const elapsedMs = Date.now() - start;
if (!result) {
this.logger.log("Deleting completed runs: No delete result", { elapsedMs });
return;
}
const total = (result.response as any)?.body?.items?.length ?? 0;
this.logger.log("Deleting completed runs: Done", { total, elapsedMs });
}
async #deleteFailedRuns() {
this.logger.log("Deleting failed runs");
const start = Date.now();
const result = await this.#deletePods({
namespace: this.namespace,
fieldSelector: "status.phase=Failed",
labelSelector: "app=task-run",
});
const elapsedMs = Date.now() - start;
if (!result) {
this.logger.log("Deleting failed runs: No delete result", { elapsedMs });
return;
}
const total = (result.response as any)?.body?.items?.length ?? 0;
this.logger.log("Deleting failed runs: Done", { total, elapsedMs });
}
async #deleteUnrecoverableRuns() {
await this.#deletePods({
namespace: this.namespace,
fieldSelector: "status.phase=?",
labelSelector: "app=task-run",
});
}
async start() {
this.enabled = true;
this.logger.log("Starting");
const completedInterval = setInterval(async () => {
if (!this.enabled) {
clearInterval(completedInterval);
return;
}
try {
await this.#deleteCompletedRuns();
} catch (error) {
this.logger.error("Error deleting completed runs", error);
}
}, this.intervalInSeconds * 1000);
const failedInterval = setInterval(
async () => {
if (!this.enabled) {
clearInterval(failedInterval);
return;
}
try {
await this.#deleteFailedRuns();
} catch (error) {
this.logger.error("Error deleting completed runs", error);
}
},
// Use a longer interval for failed runs. This is only a backup in case the task monitor fails.
2 * this.intervalInSeconds * 1000
);
// this.#launchTests();
}
async stop() {
if (!this.enabled) {
return;
}
this.enabled = false;
this.logger.log("Shutting down..");
}
async #launchTests() {
const createPod = async (
container: k8s.V1Container,
name: string,
labels?: Record<string, string>
) => {
this.logger.log("Creating pod:", name);
const pod = {
metadata: {
name,
labels,
},
spec: {
restartPolicy: "Never",
automountServiceAccountToken: false,
terminationGracePeriodSeconds: 1,
containers: [container],
},
} satisfies k8s.V1Pod;
await this.k8sClient.core
.createNamespacedPod(this.namespace, pod)
.catch(this.#handleK8sError.bind(this));
};
const createIdlePod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "docker.io/library/busybox",
command: ["sh"],
args: ["-c", "sleep infinity"],
} satisfies k8s.V1Container;
await createPod(container, name, labels);
};
const createCompletedPod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "docker.io/library/busybox",
command: ["sh"],
args: ["-c", "true"],
} satisfies k8s.V1Container;
await createPod(container, name, labels);
};
const createFailedPod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "docker.io/library/busybox",
command: ["sh"],
args: ["-c", "false"],
} satisfies k8s.V1Container;
await createPod(container, name, labels);
};
await createIdlePod("test-idle-1", { app: "task-run" });
await createFailedPod("test-failed-1", { app: "task-run" });
await createCompletedPod("test-completed-1", { app: "task-run" });
}
}
@@ -30,10 +30,12 @@ type TaskMonitorOptions = {
export class TaskMonitor {
#enabled = false;
#logger = new SimpleLogger("[TaskMonitor]");
#taskInformer: ReturnType<typeof k8s.makeInformer<k8s.V1Pod>>;
#processedPods = new Map<string, number>();
#queue = new PQueue({ concurrency: 10 });
#k8sClient: {
core: k8s.CoreV1Api;
kubeConfig: k8s.KubeConfig;
@@ -44,6 +46,10 @@ export class TaskMonitor {
private labelSelector = "app in (task-index, task-run)";
constructor(private opts: TaskMonitorOptions) {
if (opts.namespace) {
this.namespace = opts.namespace;
}
this.#k8sClient = this.#createK8sClient();
this.#taskInformer = this.#createTaskInformer();
@@ -290,6 +290,7 @@ export function TierPro({
options={concurrencyTiers.map((c) => ({ label: `Up to ${c.upto}`, value: c.code }))}
fullWidth
value={concurrentBracketCode}
variant="primary"
onChange={(v) => setConcurrentBracketCode(v)}
/>
<div className="py-6">
@@ -1,5 +1,7 @@
import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import { cn } from "~/utils/cn";
import { sortEnvironments } from "~/utils/environmentSort";
import { SimpleTooltip } from "../primitives/Tooltip";
type Environment = Pick<RuntimeEnvironment, "type">;
const variants = {
@@ -33,6 +35,74 @@ export function EnvironmentLabel({
);
}
type EnvironmentWithUsername = Environment & { userName?: string };
export function EnvironmentLabels({
environments,
size = "small",
className,
}: {
environments: EnvironmentWithUsername[];
size?: keyof typeof variants;
className?: string;
}) {
const devEnvironments = sortEnvironments(
environments.filter((env) => env.type === "DEVELOPMENT")
);
const firstDevEnvironment = devEnvironments[0];
const otherDevEnvironments = devEnvironments.slice(1);
const otherEnvironments = environments.filter((env) => env.type !== "DEVELOPMENT");
return (
<div className={cn("flex items-baseline gap-2", className)}>
{firstDevEnvironment && (
<EnvironmentLabel
environment={firstDevEnvironment}
userName={firstDevEnvironment.userName}
size={size}
/>
)}
{otherDevEnvironments.length > 0 ? (
<SimpleTooltip
disableHoverableContent
button={
<span
className={cn(
"inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
environmentBorderClassName({ type: "DEVELOPMENT" }),
environmentTextClassName({ type: "DEVELOPMENT" }),
variants[size]
)}
>
+{otherDevEnvironments.length}
</span>
}
content={
<div className="flex gap-1 py-1">
{otherDevEnvironments.map((environment, index) => (
<EnvironmentLabel
key={index}
environment={environment}
userName={environment.userName}
size={size}
/>
))}
</div>
}
/>
) : null}
{otherEnvironments.map((environment, index) => (
<EnvironmentLabel
key={index}
environment={environment}
userName={environment.userName}
size={size}
/>
))}
</div>
);
}
export function environmentTitle(environment: Environment, username?: string) {
switch (environment.type) {
case "PRODUCTION":
@@ -3,6 +3,7 @@ import {
ArrowRightIcon,
ArrowRightOnRectangleIcon,
BeakerIcon,
BellAlertIcon,
ChartBarIcon,
ClockIcon,
CursorArrowRaysIcon,
@@ -45,6 +46,7 @@ import {
v3ApiKeysPath,
v3DeploymentsPath,
v3EnvironmentVariablesPath,
v3ProjectAlertsPath,
v3ProjectPath,
v3ProjectSettingsPath,
v3RunsPath,
@@ -601,6 +603,13 @@ function V3ProjectSideMenu({
to={v3DeploymentsPath(organization, project)}
data-action="deployments"
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
iconColor="text-red-500"
to={v3ProjectAlertsPath(organization, project)}
data-action="alerts"
/>
<SideMenuItem
name="Project settings"
icon="settings"
@@ -48,11 +48,11 @@ const theme = {
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
},
secondary: {
textColor: "text-primary group-hover:text-apple-200 transition group-disabled:text-primary",
textColor: "text-secondary group-hover:text-secondary transition group-disabled:text-secondary",
button:
"bg-transparent border border-primary group-hover:border-apple-200 group-hover:bg-apple-950 group-disabled:opacity-30 group-disabled:border-primary group-disabled:bg-transparent group-disabled:pointer-events-none",
"bg-transparent border border-secondary group-hover:border-secondary group-hover:bg-secondary/10 group-disabled:opacity-30 group-disabled:border-secondary group-disabled:bg-transparent group-disabled:pointer-events-none",
shortcut:
"border-primary/30 text-apple-200 group-hover:text-text-bright/80 group-hover:border-dimmed/60",
"border-secondary/30 text-secondary group-hover:text-text-bright/80 group-hover:border-dimmed/60",
},
tertiary: {
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
@@ -49,12 +49,7 @@ export function DetailCell({
const variation = variations[variant];
return (
<div
className={cn(
"group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3 transition hover:bg-charcoal-900",
className
)}
>
<div className={cn("group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3", className)}>
<IconInBox
icon={leadingIcon}
className={cn("flex-none transition group-hover:border-charcoal-750", leadingIconClassName)}
@@ -62,20 +57,14 @@ export function DetailCell({
<div className="flex flex-1 flex-col">
<Paragraph
variant={variation.label.variant}
className={cn(
"flex-1 text-left transition group-hover:text-text-bright",
variation.label.className
)}
className={cn("flex-1 text-left", variation.label.className)}
>
{label}
</Paragraph>
{description && (
<Paragraph
variant={variation.description.variant}
className={cn(
"flex-1 text-left text-text-dimmed transition group-hover:text-text-bright",
variation.description.className
)}
className={cn("flex-1 text-left text-text-dimmed", variation.description.className)}
>
{description}
</Paragraph>
@@ -2,6 +2,19 @@ import { RadioGroup } from "@headlessui/react";
import { motion } from "framer-motion";
import { cn } from "~/utils/cn";
const variants = {
primary: {
base: "bg-charcoal-700",
active: "text-text-bright hover:bg-charcoal-750/50",
},
secondary: {
base: "bg-charcoal-700/50",
active: "text-text-bright bg-charcoal-700 rounded-[2px] border border-charcoal-600/50",
},
};
type Variants = keyof typeof variants;
type Options = {
label: string;
value: string;
@@ -12,6 +25,7 @@ type SegmentedControlProps = {
value?: string;
defaultValue?: string;
options: Options[];
variant?: Variants;
fullWidth?: boolean;
onChange?: (value: string) => void;
};
@@ -21,11 +35,18 @@ export default function SegmentedControl({
value,
defaultValue,
options,
variant = "secondary",
fullWidth,
onChange,
}: SegmentedControlProps) {
return (
<div className={cn("flex h-10 rounded bg-charcoal-700", fullWidth ? "w-full" : "w-fit")}>
<div
className={cn(
"flex h-10 rounded text-text-bright",
variants[variant].base,
fullWidth ? "w-full" : "w-fit"
)}
>
<RadioGroup
value={value}
defaultValue={defaultValue ?? options[0].value}
@@ -46,11 +67,11 @@ export default function SegmentedControl({
cn(
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
active
? "ring-offset-2 focus-visible:ring focus-visible:ring-primary focus-visible:ring-opacity-60"
? "ring-offset-2 focus-visible:ring focus-visible:ring-secondary focus-visible:ring-opacity-60"
: "",
checked
? "text-text-bright"
: "rounded-[2px] text-text-dimmed transition hover:bg-charcoal-750/50 hover:text-text-bright"
? variants[variant].active
: "text-text-dimmed transition hover:text-text-bright"
)
}
>
@@ -60,12 +81,12 @@ export default function SegmentedControl({
<div className="z-10 flex h-full w-full items-center justify-center text-sm">
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
</div>
{checked && (
{checked && variant === "primary" && (
<motion.div
layoutId={`segmented-control-${name}`}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] shadow-md outline outline-3 outline-primary"
></motion.div>
/>
)}
</div>
</>
+4
View File
@@ -174,6 +174,10 @@ Worker.init().catch((error) => {
function logError(error: unknown, request?: Request) {
console.error(error);
if (error instanceof Error && error.message.startsWith("There are locked jobs present")) {
console.log("⚠️ graphile-worker migration issue detected!");
}
}
const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer);
+3
View File
@@ -153,6 +153,9 @@ const EnvironmentSchema = z.object({
INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"),
INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"),
INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"),
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
@@ -0,0 +1,229 @@
import { WebClient } from "@slack/web-api";
import {
IntegrationService,
Organization,
OrganizationIntegration,
SecretReference,
} from "@trigger.dev/database";
import { z } from "zod";
import { $transaction, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
const SlackSecretSchema = z.object({
botAccessToken: z.string(),
userAccessToken: z.string().optional(),
expiresIn: z.number().optional(),
refreshToken: z.string().optional(),
botScopes: z.array(z.string()).optional(),
userScopes: z.array(z.string()).optional(),
raw: z.record(z.any()).optional(),
});
type SlackSecret = z.infer<typeof SlackSecretSchema>;
const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth";
type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
AuthenticatableIntegration,
"service"
> & {
service: TService;
};
type AuthenticatedClientOptions<TService extends IntegrationService> = TService extends "SLACK"
? {
forceBotToken?: boolean;
}
: undefined;
type AuthenticatedClientForIntegration<TService extends IntegrationService> =
TService extends "SLACK" ? InstanceType<typeof WebClient> : never;
export type AuthenticatableIntegration = OrganizationIntegration & {
tokenReference: SecretReference;
};
export class OrgIntegrationRepository {
static async getAuthenticatedClientForIntegration<TService extends IntegrationService>(
integration: OrganizationIntegrationForService<TService>,
options?: AuthenticatedClientOptions<TService>
): Promise<AuthenticatedClientForIntegration<TService>> {
const secretStore = getSecretStore(integration.tokenReference.provider);
switch (integration.service) {
case "SLACK": {
const secret = await secretStore.getSecret(
SlackSecretSchema,
integration.tokenReference.key
);
if (!secret) {
throw new Error("Failed to get access token");
}
// TODO refresh access token here
return new WebClient(
options?.forceBotToken
? secret.botAccessToken
: secret.userAccessToken ?? secret.botAccessToken
) as AuthenticatedClientForIntegration<TService>;
}
default: {
throw new Error(`Unsupported service ${integration.service}`);
}
}
}
static isSlackSupported =
!!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET;
static slackAuthorizationUrl(
state: string,
scopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"],
userScopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"]
) {
return `https://slack.com/oauth/v2/authorize?client_id=${
env.ORG_SLACK_INTEGRATION_CLIENT_ID
}&scope=${scopes.join(",")}&user_scope=${userScopes.join(",")}&state=${state}&redirect_uri=${
env.APP_ORIGIN
}/integrations/slack/callback`;
}
static async redirectToAuthService(
service: IntegrationService,
state: string,
request: Request,
redirectTo: string
) {
const session = await getUserSession(request);
session.set(REDIRECT_AFTER_AUTH_KEY, redirectTo);
const authUrl = service === "SLACK" ? this.slackAuthorizationUrl(state) : undefined;
if (!authUrl) {
throw new Response("Unsupported service", { status: 400 });
}
logger.debug("Redirecting to auth service", {
service,
authUrl,
redirectTo,
});
return new Response(null, {
status: 302,
headers: {
location: authUrl,
"Set-Cookie": await commitSession(session),
},
});
}
static async redirectAfterAuth(request: Request) {
const session = await getUserSession(request);
logger.debug("Redirecting back after auth", {
sessionData: session.data,
});
const redirectTo = session.get(REDIRECT_AFTER_AUTH_KEY);
if (!redirectTo) {
throw new Response("Invalid redirect", { status: 400 });
}
session.unset(REDIRECT_AFTER_AUTH_KEY);
return new Response(null, {
status: 302,
headers: {
location: redirectTo,
"Set-Cookie": await commitSession(session),
},
});
}
static async createOrgIntegration(serviceName: string, code: string, org: Organization) {
switch (serviceName) {
case "slack": {
if (!env.ORG_SLACK_INTEGRATION_CLIENT_ID || !env.ORG_SLACK_INTEGRATION_CLIENT_SECRET) {
throw new Error("Slack integration not configured");
}
const client = new WebClient();
const result = await client.oauth.v2.access({
client_id: env.ORG_SLACK_INTEGRATION_CLIENT_ID,
client_secret: env.ORG_SLACK_INTEGRATION_CLIENT_SECRET,
code,
redirect_uri: `${env.APP_ORIGIN}/integrations/slack/callback`,
});
if (result.ok) {
logger.debug("Received slack access token", {
result,
});
if (!result.access_token) {
throw new Error("Failed to get access token");
}
return await $transaction(prisma, async (tx) => {
const secretStore = getSecretStore("DATABASE", {
prismaClient: tx,
});
const integrationFriendlyId = generateFriendlyId("org_integration");
const secretValue: SlackSecret = {
botAccessToken: result.access_token!,
userAccessToken: result.authed_user ? result.authed_user.access_token : undefined,
expiresIn: result.expires_in,
refreshToken: result.refresh_token,
botScopes: result.scope ? result.scope.split(",") : [],
userScopes: result.authed_user?.scope ? result.authed_user.scope.split(",") : [],
raw: result,
};
logger.debug("Setting secret", {
secretValue,
});
await secretStore.setSecret(integrationFriendlyId, secretValue);
const reference = await tx.secretReference.create({
data: {
provider: "DATABASE",
key: integrationFriendlyId,
},
});
return await tx.organizationIntegration.create({
data: {
friendlyId: integrationFriendlyId,
organizationId: org.id,
service: "SLACK",
tokenReferenceId: reference.id,
integrationData: {
team: result.team,
user: result.authed_user
? {
id: result.authed_user.id,
}
: undefined,
} as any,
},
});
});
}
}
default: {
throw new Error(`Service ${serviceName} not supported`);
}
}
}
}
+12
View File
@@ -110,3 +110,15 @@ export async function findProjectBySlug(orgSlug: string, projectSlug: string, us
},
});
}
export async function findProjectByRef(externalRef: string, userId: string) {
// Find the project scoped to the organization, making sure the user belongs to that org
return await prisma.project.findFirst({
where: {
externalRef,
organization: {
members: { some: { userId } },
},
},
});
}
@@ -0,0 +1,33 @@
import { z } from "zod";
import { EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server";
export const ProjectAlertWebhookProperties = z.object({
secret: EncryptedSecretValueSchema,
url: z.string(),
});
export type ProjectAlertWebhookProperties = z.infer<typeof ProjectAlertWebhookProperties>;
export const ProjectAlertEmailProperties = z.object({
email: z.string(),
});
export type ProjectAlertEmailProperties = z.infer<typeof ProjectAlertEmailProperties>;
export const DeleteProjectAlertChannel = z.object({
id: z.string(),
});
export const ProjectAlertSlackProperties = z.object({
channelId: z.string(),
channelName: z.string(),
integrationId: z.string().nullish(),
});
export type ProjectAlertSlackProperties = z.infer<typeof ProjectAlertSlackProperties>;
export const ProjectAlertSlackStorage = z.object({
message_ts: z.string(),
});
export type ProjectAlertSlackStorage = z.infer<typeof ProjectAlertSlackStorage>;
+62 -21
View File
@@ -1,20 +1,21 @@
import type {
CronItem,
CronItemOptions,
Job as GraphileJob,
DbJob as GraphileJob,
Runner as GraphileRunner,
JobHelpers,
RunnerOptions,
Task,
TaskList,
TaskSpec,
WorkerUtils,
} from "graphile-worker";
import { run as graphileRun, parseCronItems } from "graphile-worker";
import { run as graphileRun, makeWorkerUtils, parseCronItems } from "graphile-worker";
import { SpanKind, trace } from "@opentelemetry/api";
import omit from "lodash.omit";
import { z } from "zod";
import { PrismaClient, PrismaClientOrTransaction } from "~/db.server";
import { $replica, PrismaClient, PrismaClientOrTransaction } from "~/db.server";
import { PgListenService } from "~/services/db/pgListen.server";
import { workerLogger as logger } from "~/services/logger.server";
import { flattenAttributes } from "@trigger.dev/core/v3";
@@ -34,8 +35,8 @@ const RawCronPayloadSchema = z.object({
const GraphileJobSchema = z.object({
id: z.coerce.string(),
queue_name: z.string().nullable(),
task_identifier: z.string(),
job_queue_id: z.number().nullable(),
task_id: z.number(),
payload: z.unknown(),
priority: z.number(),
run_at: z.coerce.date(),
@@ -72,7 +73,7 @@ type RecurringTaskPayload = {
export type ZodRecurringTasks = {
[key: string]: {
pattern: string;
match: string;
options?: CronItemOptions;
handler: (payload: RecurringTaskPayload, job: GraphileJob) => Promise<void>;
};
@@ -129,6 +130,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
#rateLimiter?: ZodWorkerRateLimiter;
#shutdownTimeoutInMs?: number;
#shuttingDown = false;
#workerUtils?: WorkerUtils;
constructor(options: ZodWorkerOptions<TMessageCatalog>) {
this.#name = options.name;
@@ -158,6 +160,8 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
const parsedCronItems = parseCronItems(this.#createCronItemsFromRecurringTasks());
this.#workerUtils = await makeWorkerUtils(this.#runnerOptions);
this.#runner = await graphileRun({
...this.#runnerOptions,
noHandleSignals: true,
@@ -188,7 +192,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
this.#logDebug("Detected incoming migration", { latestMigration });
if (latestMigration > 10) {
// already migrated past v0.14 - nothing to do
this.#logDebug("Already migrated past v0.14 - nothing to do", { latestMigration });
return;
}
@@ -263,6 +267,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
public async stop() {
await this.#runner?.stop();
await this.#workerUtils?.release();
}
public async enqueue<K extends keyof TMessageCatalog>(
@@ -442,12 +447,29 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
return taskList;
}
async #getQueueName(queueId: number | null) {
if (queueId === null) {
return;
}
const schema = z.array(z.object({ queue_name: z.string() }));
const rawQueueNameResults = await $replica.$queryRawUnsafe(
`SELECT queue_name FROM ${this.graphileWorkerSchema}._private_job_queues WHERE id = $1`,
queueId
);
const queueNameResults = schema.parse(rawQueueNameResults);
return queueNameResults[0]?.queue_name;
}
async #rescheduleTask(payload: unknown, helpers: JobHelpers) {
this.#logDebug("Rescheduling task", { payload, job: helpers.job });
await this.enqueue(helpers.job.task_identifier, payload, {
runAt: helpers.job.run_at,
queueName: helpers.job.queue_name ?? undefined,
runAt: new Date(Date.now() + 1000 * 10),
queueName: await this.#getQueueName(helpers.job.job_queue_id),
priority: helpers.job.priority,
jobKey: helpers.job.key ?? undefined,
flags: Object.keys(helpers.job.flags ?? []),
@@ -460,7 +482,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
if (this.#cleanup) {
cronItems.push({
pattern: this.#cleanup.frequencyExpression,
match: this.#cleanup.frequencyExpression,
identifier: CLEANUP_TASK_NAME,
task: CLEANUP_TASK_NAME,
options: this.#cleanup.taskOptions,
@@ -469,7 +491,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
if (this.#reporter) {
cronItems.push({
pattern: "50 * * * *", // Every hour at 50 minutes past the hour
match: "50 * * * *", // Every hour at 50 minutes past the hour
identifier: REPORTER_TASK_NAME,
task: REPORTER_TASK_NAME,
});
@@ -481,7 +503,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
for (const [key, task] of Object.entries(this.#recurringTasks)) {
const cronItem: CronItem = {
pattern: task.pattern,
match: task.match,
identifier: key,
task: key,
options: task.options,
@@ -529,7 +551,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
attributes: {
"job.task_identifier": job.task_identifier,
"job.id": job.id,
...(job.queue_name ? { "job.queue_name": job.queue_name } : {}),
...(job.job_queue_id ? { "job.queue_id": job.job_queue_id } : {}),
...flattenAttributes(job.payload as Record<string, unknown>, "job.payload"),
"job.priority": job.priority,
"job.run_at": job.run_at.toISOString(),
@@ -599,7 +621,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
attributes: {
"job.task_identifier": job.task_identifier,
"job.id": job.id,
...(job.queue_name ? { "job.queue_name": job.queue_name } : {}),
...(job.job_queue_id ? { "job.queue_id": job.job_queue_id } : {}),
...flattenAttributes(job.payload as Record<string, unknown>, "job.payload"),
"job.priority": job.priority,
"job.run_at": job.run_at.toISOString(),
@@ -638,6 +660,10 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
return;
}
if (!this.#workerUtils) {
throw new Error("WorkerUtils need to be initialized before running job cleanup.");
}
const job = helpers.job;
logger.debug("Received cleanup task", {
@@ -663,23 +689,38 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
payload,
});
const rawResults = await this.#prisma.$queryRawUnsafe(
`WITH rows AS (SELECT id FROM ${this.graphileWorkerSchema}.jobs WHERE run_at < $1 AND locked_at IS NULL AND max_attempts = attempts LIMIT $2 FOR UPDATE) DELETE FROM ${this.graphileWorkerSchema}.jobs WHERE id IN (SELECT id FROM rows) RETURNING id`,
const rawResults = await $replica.$queryRawUnsafe(
`SELECT id
FROM ${this.graphileWorkerSchema}.jobs
WHERE run_at < $1
AND locked_at IS NULL
AND max_attempts = attempts
LIMIT $2`,
expirationDate,
this.#cleanup.maxCount
);
const results = Array.isArray(rawResults) ? rawResults : [];
const results = z
.array(
z.object({
id: z.coerce.string(),
})
)
.parse(rawResults);
const completedJobs = await this.#workerUtils.completeJobs(results.map((job) => job.id));
logger.debug("Cleaned up old jobs", {
count: results.length,
found: results.length,
deleted: completedJobs.length,
expirationDate,
payload,
});
if (this.#reporter) {
await this.#reporter("cleanup_stats", {
count: results.length,
found: results.length,
deleted: completedJobs.length,
expirationDate,
ts: payload._cron.ts,
});
@@ -711,7 +752,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
const schema = z.array(z.object({ count: z.coerce.number() }));
// Count the number of jobs that have been added since the startAt date and before the payload._cron.ts date
const rawAddedResults = await this.#prisma.$queryRawUnsafe(
const rawAddedResults = await $replica.$queryRawUnsafe(
`SELECT COUNT(*) FROM ${this.graphileWorkerSchema}.jobs WHERE created_at > $1 AND created_at < $2`,
startAt,
payload._cron.ts
@@ -720,7 +761,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
const addedCountResults = schema.parse(rawAddedResults)[0];
// Count the total number of jobs in the jobs table
const rawTotalResults = await this.#prisma.$queryRawUnsafe(
const rawTotalResults = await $replica.$queryRawUnsafe(
`SELECT COUNT(*) FROM ${this.graphileWorkerSchema}.jobs`
);
@@ -14,7 +14,7 @@ import {
IndexEndpointStats,
parseEndpointIndexStats,
} from "@trigger.dev/core";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
export type Client = {
slug: string;
@@ -1,6 +1,6 @@
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { projectPath } from "~/utils/pathBuilder";
@@ -2,7 +2,7 @@ import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
export class ProjectPresenter {
#prismaClient: PrismaClient;
@@ -0,0 +1,73 @@
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
import { ProjectAlertChannel } from "@trigger.dev/database";
import { decryptSecret } from "~/services/secrets/secretStore.server";
import { env } from "~/env.server";
import {
ProjectAlertEmailProperties,
ProjectAlertSlackProperties,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
export type AlertChannelListPresenterData = Awaited<ReturnType<AlertChannelListPresenter["call"]>>;
export type AlertChannelListPresenterRecord =
AlertChannelListPresenterData["alertChannels"][number];
export type AlertChannelListPresenterAlertProperties = NonNullable<
AlertChannelListPresenterRecord["properties"]
>;
export class AlertChannelListPresenter extends BasePresenter {
public async call(projectId: string) {
logger.debug("AlertChannelListPresenter", { projectId });
const alertChannels = await this._prisma.projectAlertChannel.findMany({
where: {
projectId,
},
orderBy: {
createdAt: "desc",
},
});
return {
alertChannels: await Promise.all(
alertChannels.map(async (alertChannel) => ({
...alertChannel,
properties: await this.#presentProperties(alertChannel),
}))
),
};
}
async #presentProperties(alertChannel: ProjectAlertChannel) {
if (!alertChannel.properties) {
return;
}
switch (alertChannel.type) {
case "WEBHOOK":
const parsedProperties = ProjectAlertWebhookProperties.parse(alertChannel.properties);
const secret = await decryptSecret(env.ENCRYPTION_KEY, parsedProperties.secret);
return {
type: "WEBHOOK" as const,
url: parsedProperties.url,
secret,
};
case "EMAIL":
return {
type: "EMAIL" as const,
...ProjectAlertEmailProperties.parse(alertChannel.properties),
};
case "SLACK": {
return {
type: "SLACK" as const,
...ProjectAlertSlackProperties.parse(alertChannel.properties),
};
}
default:
throw new Error(`Unsupported alert channel type: ${alertChannel.type}`);
}
}
}
@@ -0,0 +1,131 @@
import {
ProjectAlertChannel,
ProjectAlertChannelType,
ProjectAlertType,
} from "@trigger.dev/database";
import assertNever from "assert-never";
import { z } from "zod";
import { env } from "~/env.server";
import {
ProjectAlertEmailProperties,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
import { decryptSecret } from "~/services/secrets/secretStore.server";
export const ApiAlertType = z.enum(["attempt_failure", "deployment_failure", "deployment_success"]);
export type ApiAlertType = z.infer<typeof ApiAlertType>;
export const ApiAlertChannel = z.enum(["email", "webhook"]);
export type ApiAlertChannel = z.infer<typeof ApiAlertChannel>;
export const ApiAlertChannelData = z.object({
email: z.string().optional(),
url: z.string().optional(),
secret: z.string().optional(),
});
export type ApiAlertChannelData = z.infer<typeof ApiAlertChannelData>;
export const ApiCreateAlertChannel = z.object({
alertTypes: ApiAlertType.array(),
name: z.string(),
channel: ApiAlertChannel,
channelData: ApiAlertChannelData,
deduplicationKey: z.string().optional(),
});
export type ApiCreateAlertChannel = z.infer<typeof ApiCreateAlertChannel>;
export const ApiAlertChannelObject = z.object({
id: z.string(),
name: z.string(),
alertTypes: ApiAlertType.array(),
channel: ApiAlertChannel,
channelData: ApiAlertChannelData,
deduplicationKey: z.string().optional(),
});
export type ApiAlertChannelObject = z.infer<typeof ApiAlertChannelObject>;
export class ApiAlertChannelPresenter {
public static async alertChannelToApi(
alertChannel: ProjectAlertChannel
): Promise<ApiAlertChannelObject> {
return {
id: alertChannel.friendlyId,
name: alertChannel.name,
alertTypes: alertChannel.alertTypes.map((type) => this.alertTypeToApi(type)),
channel: this.alertChannelTypeToApi(alertChannel.type),
channelData: await channelDataFromProperties(alertChannel.type, alertChannel.properties),
deduplicationKey: alertChannel.userProvidedDeduplicationKey
? alertChannel.deduplicationKey
: undefined,
};
}
public static alertTypeToApi(alertType: ProjectAlertType): ApiAlertType {
switch (alertType) {
case "TASK_RUN_ATTEMPT":
return "attempt_failure";
case "DEPLOYMENT_FAILURE":
return "deployment_failure";
case "DEPLOYMENT_SUCCESS":
return "deployment_success";
default:
assertNever(alertType);
}
}
public static alertTypeFromApi(alertType: ApiAlertType): ProjectAlertType {
switch (alertType) {
case "attempt_failure":
return "TASK_RUN_ATTEMPT";
case "deployment_failure":
return "DEPLOYMENT_FAILURE";
case "deployment_success":
return "DEPLOYMENT_SUCCESS";
default:
assertNever(alertType);
}
}
public static alertChannelTypeToApi(type: ProjectAlertChannelType): ApiAlertChannel {
switch (type) {
case "EMAIL":
return "email";
case "WEBHOOK":
return "webhook";
case "SLACK":
throw new Error("Slack channels are not supported");
default:
assertNever(type);
}
}
}
async function channelDataFromProperties(
type: ProjectAlertChannelType,
properties: ProjectAlertChannel["properties"]
): Promise<ApiAlertChannelData> {
if (!properties) {
return {};
}
switch (type) {
case "EMAIL":
return ProjectAlertEmailProperties.parse(properties);
case "WEBHOOK":
const { url, secret } = ProjectAlertWebhookProperties.parse(properties);
return {
url,
secret: await decryptSecret(env.ENCRYPTION_KEY, secret),
};
case "SLACK":
throw new Error("Slack channels are not supported");
default:
assertNever(type);
}
}
@@ -1,7 +1,7 @@
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
export class ApiKeysPresenter {
#prismaClient: PrismaClient;
@@ -144,18 +144,18 @@ export class DeploymentPresenter {
userName: getUsername(deployment.environment.orgMember?.user),
},
deployedBy: deployment.triggeredBy,
errorData: this.#prepareErrorData(deployment.errorData),
sdkVersion: deployment.worker?.sdkVersion,
imageReference: deployment.imageReference,
externalBuildData:
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
projectId: deployment.projectId,
organizationId: project.organizationId,
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
},
};
}
#prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
if (!errorData) {
return;
}
@@ -1,7 +1,7 @@
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
@@ -0,0 +1,104 @@
import {
AuthenticatableIntegration,
OrgIntegrationRepository,
} from "~/models/orgIntegration.server";
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
import { WebClient } from "@slack/web-api";
export class NewAlertChannelPresenter extends BasePresenter {
public async call(projectId: string) {
const project = await this._prisma.project.findUniqueOrThrow({
where: {
id: projectId,
},
});
// Find the latest Slack integration
const slackIntegration = await this._prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: project.organizationId,
},
orderBy: {
createdAt: "desc",
},
include: {
tokenReference: true,
},
});
// If there is a slack integration, then we need to get a list of Slack Channels
if (slackIntegration) {
const channels = await getSlackChannelsForToken(slackIntegration);
return {
slack: {
status: "READY" as const,
channels,
integrationId: slackIntegration.id,
},
};
} else {
if (OrgIntegrationRepository.isSlackSupported) {
return {
slack: {
status: "NOT_CONFIGURED" as const,
},
};
} else {
return {
slack: {
status: "NOT_AVAILABLE" as const,
},
};
}
}
}
}
async function getSlackChannelsForToken(integration: AuthenticatableIntegration) {
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(integration);
const channels = await getAllSlackConversations(client);
logger.debug("Received a list of slack conversations", {
channels,
});
return (channels ?? [])
.filter((channel) => !channel.is_archived)
.filter((channel) => channel.is_channel)
.filter((channel) => !channel.is_ext_shared)
.filter((channel) => channel.unlinked === 0)
.filter((channel) => channel.num_members)
.sort((a, b) => a.name!.localeCompare(b.name!));
}
type Channels = Awaited<ReturnType<WebClient["conversations"]["list"]>>["channels"];
async function getSlackConversationsPage(client: WebClient, nextCursor?: string) {
return client.conversations.list({
types: "public_channel,private_channel",
exclude_archived: true,
cursor: nextCursor,
});
}
async function getAllSlackConversations(client: WebClient) {
let nextCursor: string | undefined = undefined;
let channels: Channels = [];
do {
const response = await getSlackConversationsPage(client, nextCursor);
if (!response.ok) {
throw new Error(`Failed to get channels: ${response.error}`);
}
channels = channels.concat(response.channels ?? []);
nextCursor = response.response_metadata?.next_cursor;
} while (nextCursor);
return channels;
}
@@ -10,7 +10,7 @@ import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
@@ -1,7 +1,7 @@
import { TaskTriggerSource } from "@trigger.dev/database";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { TestSearchParams } from "~/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { createSearchParams } from "~/utils/searchParams";
import { getUsername } from "~/utils/username";
@@ -11,7 +11,7 @@ import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/component
import { StepContentContainer } from "~/components/StepContentContainer";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
@@ -261,15 +261,7 @@ export default function Page() {
</Suspense>
</TableCell>
<TableCell to={path}>
<div className="space-x-2">
{task.environments.map((environment) => (
<EnvironmentLabel
key={environment.id}
environment={environment}
userName={environment.userName}
/>
))}
</div>
<EnvironmentLabels environments={task.environments} />
</TableCell>
<TableCellChevron to={path} />
</TableRow>
@@ -0,0 +1,50 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import {
redirectBackWithSuccessMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { findProjectBySlug } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
import { getUserSession } from "~/services/sessionStorage.server";
import {
ProjectParamSchema,
v3NewProjectAlertPath,
v3NewProjectAlertPathConnectToSlackPath,
} from "~/utils/pathBuilder";
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Find an integration for Slack for this org
const integration = await prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: project.organizationId,
},
});
if (integration) {
return redirectWithSuccessMessage(
`${v3NewProjectAlertPath({ slug: organizationSlug }, project)}?option=slack`,
request,
"Successfully connected your Slack workspace"
);
} else {
// Redirect to Slack
return await OrgIntegrationRepository.redirectToAuthService(
"SLACK",
project.organizationId,
request,
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project)
);
}
}
@@ -0,0 +1,397 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { HashtagIcon, LockClosedIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/router";
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { SlackIcon } from "@trigger.dev/companyicons";
import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Checkbox } from "~/components/primitives/Checkbox";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import SegmentedControl from "~/components/primitives/SegmentedControl";
import { Select, SelectItem } from "~/components/primitives/Select";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, v3ProjectAlertsPath } from "~/utils/pathBuilder";
import {
CreateAlertChannelOptions,
CreateAlertChannelService,
} from "~/v3/services/alerts/createAlertChannel.server";
const FormSchema = z
.object({
alertTypes: z
.array(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"]))
.min(1)
.or(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])),
type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"),
channelValue: z.string().nonempty(),
integrationId: z.string().optional(),
})
.refine(
(value) =>
value.type === "EMAIL" ? z.string().email().safeParse(value.channelValue).success : true,
{
message: "Must be a valid email address",
path: ["channelValue"],
}
)
.refine(
(value) =>
value.type === "WEBHOOK" ? z.string().url().safeParse(value.channelValue).success : true,
{
message: "Must be a valid URL",
path: ["channelValue"],
}
)
.refine(
(value) =>
value.type === "SLACK"
? typeof value.channelValue === "string" && value.channelValue.startsWith("C")
: true,
{
message: "Must select a Slack channel",
path: ["channelValue"],
}
);
function formDataToCreateAlertChannelOptions(
formData: z.infer<typeof FormSchema>
): CreateAlertChannelOptions {
switch (formData.type) {
case "WEBHOOK": {
return {
name: `Webhook to ${new URL(formData.channelValue).hostname}`,
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
channel: {
type: "WEBHOOK",
url: formData.channelValue,
},
};
}
case "EMAIL": {
return {
name: `Email to ${formData.channelValue}`,
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
channel: {
type: "EMAIL",
email: formData.channelValue,
},
};
}
case "SLACK": {
const [channelId, channelName] = formData.channelValue.split("/");
return {
name: `Slack message to ${channelName}`,
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
channel: {
type: "SLACK",
channelId,
channelName,
integrationId: formData.integrationId,
},
};
}
}
}
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const presenter = new NewAlertChannelPresenter();
const results = await presenter.call(project.id);
const url = new URL(request.url);
const option = url.searchParams.get("option");
return typedjson({
...results,
option: option === "slack" ? ("SLACK" as const) : undefined,
});
}
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const formData = await request.formData();
const submission = parse(formData, { schema: FormSchema });
if (!submission.value) {
return json(submission);
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
submission.error.key = "Project not found";
return json(submission);
}
const service = new CreateAlertChannelService();
const alertChannel = await service.call(
project.externalRef,
userId,
formDataToCreateAlertChannelOptions(submission.value)
);
if (!alertChannel) {
submission.error.key = "Failed to create alert channel";
return json(submission);
}
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Created ${alertChannel.name} alert`
);
};
export default function Page() {
const [isOpen, setIsOpen] = useState(false);
const { slack, option } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const navigation = useNavigation();
const navigate = useNavigate();
const organization = useOrganization();
const project = useProject();
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "create";
const [form, { channelValue: channelValue, alertTypes, type, integrationId }] = useForm({
id: "create-alert",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: FormSchema });
},
shouldRevalidate: "onSubmit",
});
useEffect(() => {
setIsOpen(true);
}, []);
useEffect(() => {
if (navigation.state !== "idle") return;
if (lastSubmission !== undefined) return;
form.ref.current?.reset();
}, [navigation.state, lastSubmission]);
return (
<Dialog
open={isOpen}
onOpenChange={(o) => {
if (!o) {
navigate(v3ProjectAlertsPath(organization, project));
}
}}
>
<DialogContent>
<DialogHeader>New alert</DialogHeader>
<Form method="post" {...form.props}>
<Fieldset className="mt-2">
<InputGroup fullWidth>
<SegmentedControl
{...conform.input(type)}
options={[
{ label: "Email", value: "EMAIL" },
{ label: "Slack", value: "SLACK" },
{ label: "Webhook", value: "WEBHOOK" },
]}
onChange={(value) => {
setCurrentAlertChannel(value);
}}
fullWidth
defaultValue={currentAlertChannel ?? undefined}
/>
</InputGroup>
{currentAlertChannel === "EMAIL" ? (
<InputGroup fullWidth>
<Label>Email</Label>
<Input
{...conform.input(channelValue)}
placeholder="email@youremail.com"
type="email"
autoFocus
/>
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
</InputGroup>
) : currentAlertChannel === "SLACK" ? (
<InputGroup fullWidth>
{slack.status === "READY" ? (
<>
<Select
{...conform.select(channelValue)}
placeholder="Select a Slack channel"
heading="Filter channels…"
defaultValue={undefined}
dropdownIcon
variant="tertiary/medium"
items={slack.channels}
filter={(channel, search) =>
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
}
text={(value) => {
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
if (!channel) return;
return <SlackChannelTitle {...channel} />;
}}
>
{(matches) => (
<>
{matches?.map((channel) => (
<SelectItem key={channel.id} value={`${channel.id}/${channel.name}`}>
<SlackChannelTitle {...channel} />
</SelectItem>
))}
</>
)}
</Select>
<Hint className="leading-relaxed">
If selecting a private channel, you will need to invite the bot to the channel
using <InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>
</Hint>
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
<input type="hidden" name="integrationId" value={slack.integrationId} />
</>
) : slack.status === "NOT_CONFIGURED" ? (
<LinkButton variant="tertiary/large" to="connect-to-slack" fullWidth>
<span className="flex items-center gap-2 text-text-bright">
<SlackIcon className="size-5" /> Connect to Slack
</span>
</LinkButton>
) : (
<Callout variant="warning">
Slack integration is not available. Please contact your organization
administrator.
</Callout>
)}
</InputGroup>
) : (
<InputGroup fullWidth>
<Label>URL</Label>
<Input
{...conform.input(channelValue)}
placeholder="https://foobar.com/webhooks"
type="url"
autoFocus
/>
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
<Hint>We'll issue POST requests to this URL with a JSON payload.</Hint>
</InputGroup>
)}
<InputGroup fullWidth>
<Label>Events</Label>
<Checkbox
name={alertTypes.name}
id="TASK_RUN_ATTEMPT"
value="TASK_RUN_ATTEMPT"
variant="simple/small"
label="Task run failure"
defaultChecked
/>
<Checkbox
name={alertTypes.name}
id="DEPLOYMENT_FAILURE"
value="DEPLOYMENT_FAILURE"
variant="simple/small"
label="Deployment failure"
defaultChecked
/>
<Checkbox
name={alertTypes.name}
id="DEPLOYMENT_SUCCESS"
value="DEPLOYMENT_SUCCESS"
variant="simple/small"
label="Deployment success"
defaultChecked
/>
<FormError id={alertTypes.errorId}>{alertTypes.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<div className="border-t border-grid-bright pt-3">
<FormButtons
confirmButton={
<Button
variant="primary/medium"
disabled={isLoading}
name="action"
value="create"
>
{isLoading ? "Saving…" : "Save"}
</Button>
}
cancelButton={
<LinkButton
to={v3ProjectAlertsPath(organization, project)}
variant="tertiary/medium"
>
Cancel
</LinkButton>
}
/>
</div>
</Fieldset>
</Form>
</DialogContent>
</Dialog>
);
}
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
return (
<div className="flex items-center gap-1.5">
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
<span>{name}</span>
</div>
);
}
@@ -0,0 +1,445 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
BoltIcon,
BoltSlashIcon,
BookOpenIcon,
EnvelopeIcon,
GlobeAltIcon,
LockClosedIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { SlackIcon } from "@trigger.dev/companyicons";
import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DetailCell } from "~/components/primitives/DetailCell";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import {
AlertChannelListPresenter,
AlertChannelListPresenterRecord,
} from "~/presenters/v3/AlertChannelListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
ProjectParamSchema,
docsPath,
v3NewProjectAlertPath,
v3ProjectAlertsPath,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const presenter = new AlertChannelListPresenter();
const data = await presenter.call(project.id);
return typedjson(data);
};
const schema = z.discriminatedUnion("action", [
z.object({ action: z.literal("delete"), id: z.string() }),
z.object({ action: z.literal("disable"), id: z.string() }),
z.object({ action: z.literal("enable"), id: z.string() }),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const formData = await request.formData();
const submission = parse(formData, { schema });
if (!submission.value) {
return json(submission);
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
submission.error.key = "Project not found";
return json(submission);
}
switch (submission.value.action) {
case "delete": {
const alertChannel = await prisma.projectAlertChannel.delete({
where: { id: submission.value.id, projectId: project.id },
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Deleted ${alertChannel.name} alert`
);
}
case "disable": {
const alertChannel = await prisma.projectAlertChannel.update({
where: { id: submission.value.id, projectId: project.id },
data: { enabled: false },
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Disabled ${alertChannel.name} alert`
);
}
case "enable": {
const alertChannel = await prisma.projectAlertChannel.update({
where: { id: submission.value.id, projectId: project.id },
data: { enabled: true },
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Enabled ${alertChannel.name} alert`
);
}
}
};
export default function Page() {
const { alertChannels } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
return (
<PageContainer>
<NavBar>
<PageTitle title="Alerts" />
<PageAccessories>
<LinkButton
LeadingIcon={BookOpenIcon}
to={docsPath("v3/project-alerts")}
variant="minimal/small"
>
Alerts docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody>
<div className={cn("flex h-full flex-col gap-3")}>
<div className="flex items-center justify-end gap-2">
<LinkButton
to={v3NewProjectAlertPath(organization, project)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Alert Types</TableHeaderCell>
<TableHeaderCell>Channel</TableHeaderCell>
<TableHeaderCell>Enabled</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{alertChannels.length > 0 ? (
alertChannels.map((alertChannel) => (
<TableRow key={alertChannel.id}>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.name}
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<AlertChannelDetails alertChannel={alertChannel} />
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<EnabledStatus enabled={alertChannel.enabled} />
</TableCell>
<TableCellMenu isSticky>
{alertChannel.enabled ? (
<DisableAlertChannelButton id={alertChannel.id} />
) : (
<EnableAlertChannelButton id={alertChannel.id} />
)}
<DeleteAlertChannelButton id={alertChannel.id} />
</TableCellMenu>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5}>
<div className="flex items-center justify-center">
<Paragraph>No alerts have been created</Paragraph>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Outlet />
</PageBody>
</PageContainer>
);
}
function DeleteAlertChannelButton(props: { id: string }) {
const lastSubmission = useActionData();
const navigation = useNavigation();
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
const [form, { id }] = useForm({
id: "delete-alert-channel",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Form method="post" {...form.props}>
<input type="hidden" name="id" value={props.id} />
<Button
name="action"
value="delete"
type="submit"
variant="small-menu-item"
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500"
className="text-xs"
>
{isLoading ? "Deleting" : "Delete"}
</Button>
</Form>
);
}
function DisableAlertChannelButton(props: { id: string }) {
const lastSubmission = useActionData();
const navigation = useNavigation();
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
const [form, { id }] = useForm({
id: "disable-alert-channel",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Form method="post" {...form.props}>
<input type="hidden" name="id" value={props.id} />
<Button
name="action"
value="disable"
type="submit"
variant="small-menu-item"
LeadingIcon={BoltSlashIcon}
leadingIconClassName="text-dimmed"
className="text-xs"
>
{isLoading ? "Disabling" : "Disable"}
</Button>
</Form>
);
}
function EnableAlertChannelButton(props: { id: string }) {
const lastSubmission = useActionData();
const navigation = useNavigation();
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
const [form, { id }] = useForm({
id: "enable-alert-channel",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Form method="post" {...form.props}>
<input type="hidden" name="id" value={props.id} />
<Button
name="action"
value="enable"
type="submit"
variant="small-menu-item"
LeadingIcon={BoltIcon}
leadingIconClassName="text-success"
className="text-xs"
>
{isLoading ? "Enabling" : "Enable"}
</Button>
</Form>
);
}
function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListPresenterRecord }) {
switch (alertChannel.properties?.type) {
case "EMAIL": {
return (
<DetailCell
leadingIcon={
<AlertChannelTypeIcon
channelType={alertChannel.type}
className="size-5 text-charcoal-400"
/>
}
leadingIconClassName="text-charcoal-400"
label={"Email"}
description={alertChannel.properties.email}
/>
);
}
case "WEBHOOK": {
return (
<DetailCell
leadingIcon={
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertChannelTypeIcon
channelType={alertChannel.type}
className="size-5 text-charcoal-400"
/>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1">Webhook</TooltipContent>
</Tooltip>
</TooltipProvider>
}
leadingIconClassName="text-charcoal-400"
label={alertChannel.properties.url}
description={
<ClipboardField
value={alertChannel.properties.secret}
variant="secondary/small"
icon={<LockClosedIcon className="size-4" />}
iconButton
secure={"•".repeat(alertChannel.properties.secret.length)}
className="mt-1 w-80"
/>
}
/>
);
}
case "SLACK": {
return (
<DetailCell
leadingIcon={
<AlertChannelTypeIcon
channelType={alertChannel.type}
className="size-5 text-charcoal-400"
/>
}
leadingIconClassName="text-charcoal-400"
label={"Slack"}
description={`#${alertChannel.properties.channelName}`}
/>
);
}
}
return null;
}
export function alertTypeTitle(alertType: ProjectAlertType): string {
switch (alertType) {
case "TASK_RUN_ATTEMPT":
return "Task attempt failure";
case "DEPLOYMENT_FAILURE":
return "Deployment failure";
case "DEPLOYMENT_SUCCESS":
return "Deployment success";
default: {
assertNever(alertType);
}
}
}
export function AlertChannelTypeIcon({
channelType,
className,
}: {
channelType: ProjectAlertChannelType;
className: string;
}) {
switch (channelType) {
case "EMAIL":
return <EnvelopeIcon className={className} />;
case "SLACK":
return <SlackIcon className={className} />;
case "WEBHOOK":
return <GlobeAltIcon className={className} />;
default: {
assertNever(channelType);
}
}
}
@@ -46,13 +46,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
try {
const presenter = new EnvironmentVariablesPresenter();
const { environmentVariables, environments } = await presenter.call({
const { environments } = await presenter.call({
userId,
projectSlug: projectParam,
});
return typedjson({
environmentVariables,
environments,
});
} catch (error) {
@@ -150,7 +149,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
export default function Page() {
const [isOpen, setIsOpen] = useState(false);
const { environmentVariables, environments } = useTypedLoaderData<typeof loader>();
const { environments } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const navigation = useNavigation();
const navigate = useNavigate();
@@ -8,7 +8,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import {
@@ -211,16 +211,7 @@ export default function Page() {
</div>
</Property>
<Property label="Environments">
<div className="flex flex-wrap gap-1">
{schedule.environments.map((env) => (
<EnvironmentLabel
key={env.id}
size="small"
environment={env}
userName={env.userName}
/>
))}
</div>
<EnvironmentLabels size="small" environments={schedule.environments} />
</Property>
<Property label="External ID">
{schedule.externalId ? schedule.externalId : ""}
@@ -6,7 +6,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
@@ -281,15 +281,7 @@ function SchedulesTable({
{schedule.lastRun ? <DateTime date={schedule.lastRun} timeZone="utc" /> : ""}
</TableCell>
<TableCell to={path} className={cellClass}>
<div className="flex gap-1">
{schedule.environments.map((environment) => (
<EnvironmentLabel
key={environment.id}
environment={environment}
userName={environment.userName}
/>
))}
</div>
<EnvironmentLabels environments={schedule.environments} size="small" />
</TableCell>
<TableCell to={path}>
<EnabledStatus enabled={schedule.active} />
@@ -0,0 +1,94 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import {
ApiAlertChannelPresenter,
ApiCreateAlertChannel,
} from "~/presenters/v3/ApiAlertChannelPresenter.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { CreateAlertChannelService } from "~/v3/services/alerts/createAlertChannel.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}
const { projectRef } = parsedParams.data;
const rawBody = await request.json();
const body = ApiCreateAlertChannel.safeParse(rawBody);
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const service = new CreateAlertChannelService();
try {
if (body.data.channel === "email") {
if (!body.data.channelData.email) {
return json({ error: "Email is required" }, { status: 422 });
}
const alertChannel = await service.call(projectRef, authenticationResult.userId, {
name: body.data.name,
alertTypes: body.data.alertTypes.map((type) =>
ApiAlertChannelPresenter.alertTypeFromApi(type)
),
channel: {
type: "EMAIL",
email: body.data.channelData.email,
},
deduplicationKey: body.data.deduplicationKey,
});
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
}
if (body.data.channel === "webhook") {
if (!body.data.channelData.url) {
return json({ error: "webhook url is required" }, { status: 422 });
}
const alertChannel = await service.call(projectRef, authenticationResult.userId, {
name: body.data.name,
alertTypes: body.data.alertTypes.map((type) =>
ApiAlertChannelPresenter.alertTypeFromApi(type)
),
channel: {
type: "WEBHOOK",
url: body.data.channelData.url,
secret: body.data.channelData.secret,
},
deduplicationKey: body.data.deduplicationKey,
});
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
}
return json({ error: "Invalid channel type" }, { status: 422 });
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
}
return json(
{ error: error instanceof Error ? error.message : "Internal Server Error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,66 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import z from "zod";
import { redirectBackWithErrorMessage } from "~/models/message.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { requireUserId } from "~/services/session.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { CreateOrgIntegrationService } from "~/v3/services/createOrgIntegration.server";
const URLSearchSchema = z
.object({
code: z.string().optional(),
state: z.string().optional(),
error: z.string().optional(),
})
.passthrough();
const ParamsSchema = z.object({
serviceName: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() !== "GET") {
return { status: 405, body: "Method Not Allowed" };
}
const userId = await requireUserId(request);
const url = requestUrl(request);
const parsedSearchParams = URLSearchSchema.safeParse(Object.fromEntries(url.searchParams));
if (!parsedSearchParams.success) {
// TODO: this needs to lookup the redirect url in the cookies
throw new Response("Invalid params", { status: 400 });
}
if (parsedSearchParams.data.error) {
// TODO: this needs to lookup the redirect url in the cookies
throw new Response(parsedSearchParams.data.error, { status: 400 });
}
if (!parsedSearchParams.data.code || !parsedSearchParams.data.state) {
throw new Response("Invalid params", { status: 400 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
throw new Response("Invalid params", { status: 400 });
}
const service = new CreateOrgIntegrationService();
const integration = await service.call(
userId,
parsedSearchParams.data.state,
parsedParams.data.serviceName,
parsedSearchParams.data.code
);
if (integration) {
return await OrgIntegrationRepository.redirectAfterAuth(request);
}
return redirectBackWithErrorMessage(request, "Failed to connect to the service");
}
@@ -0,0 +1,5 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
export function action({ request }: ActionFunctionArgs) {
return new Response(null, { status: 200 });
}
@@ -278,10 +278,8 @@ export default function Story() {
<span className="text-charcoal-900">Continue with GitHub</span>
</Button>
<Button variant="secondary/large" fullWidth>
<EnvelopeIcon
className={"mr-1.5 h-5 w-5 text-primary transition group-hover:text-apple-200"}
/>
<span className="text-primary group-hover:text-apple-200">Continue with Email</span>
<EnvelopeIcon className={"mr-1.5 h-5 w-5 text-secondary transition"} />
<span className="text-secondary">Continue with Email</span>
</Button>
<Button variant="tertiary/large" fullWidth>
<GitHubLightIcon className={"mr-1.5 size-[1.2rem]"} />
@@ -308,10 +306,8 @@ export default function Story() {
<span className="text-charcoal-900">Continue with GitHub</span>
</Button>
<Button variant="secondary/extra-large" fullWidth>
<EnvelopeIcon
className={"mr-1.5 h-5 w-5 text-primary transition group-hover:text-apple-200"}
/>
<span className="text-primary group-hover:text-apple-200">Continue with Email</span>
<EnvelopeIcon className={"mr-1.5 h-5 w-5 text-secondary transition"} />
<span className="text-secondary">Continue with Email</span>
</Button>
<Button variant="tertiary/extra-large" fullWidth>
<GitHubLightIcon className={"mr-1.5 h-5 w-5"} />
@@ -14,6 +14,13 @@ export default function Story() {
>
{isDisabled ? "Enable checkboxes" : "Disable checkboxes"}
</Button>
<Checkbox
name="Simple checkbox"
id="check1"
variant="simple/small"
label="This is a simple small checkbox"
disabled={isDisabled}
/>
<Checkbox
name="Simple checkbox"
id="check1"
@@ -1,4 +1,5 @@
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Paragraph } from "~/components/primitives/Paragraph";
import SegmentedControl from "~/components/primitives/SegmentedControl";
const options = [
@@ -8,8 +9,15 @@ const options = [
export default function Story() {
return (
<MainCenteredContainer>
<SegmentedControl name="name" options={options} />
<MainCenteredContainer className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<Paragraph>Primary</Paragraph>
<SegmentedControl name="name" options={options} variant="primary" />
</div>
<div className="flex flex-col gap-2">
<Paragraph>Secondary</Paragraph>
<SegmentedControl name="name" options={options} variant="secondary" />
</div>
</MainCenteredContainer>
);
}
@@ -0,0 +1,95 @@
import { runMigrations } from "graphile-worker";
import { PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { PgNotifyService } from "./pgNotify.server";
import { z } from "zod";
export class GraphileMigrationHelperService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call() {
this.#logDebug("GraphileMigrationHelperService.call");
await this.#detectAndPrepareForMigrations();
await runMigrations({
connectionString: env.DATABASE_URL,
schema: env.WORKER_SCHEMA,
});
}
#logDebug(message: string, args?: any) {
logger.debug(`[migrationHelper] ${message}`, args);
}
async #getLatestMigration() {
const migrationQueryResult = await this.#prismaClient.$queryRawUnsafe(`
SELECT id FROM ${env.WORKER_SCHEMA}.migrations
ORDER BY id DESC LIMIT 1
`);
const MigrationQueryResultSchema = z.array(z.object({ id: z.number() }));
const migrationResults = MigrationQueryResultSchema.parse(migrationQueryResult);
if (!migrationResults.length) {
// no migrations applied yet
return -1;
}
return migrationResults[0].id;
}
async #graphileSchemaExists() {
const schemaCount = await this.#prismaClient.$executeRaw`
SELECT schema_name FROM information_schema.schemata
WHERE schema_name = ${env.WORKER_SCHEMA}
`;
return schemaCount === 1;
}
/** Helper for graphile-worker v0.14.0 migration. No-op if already migrated. */
async #detectAndPrepareForMigrations() {
if (!(await this.#graphileSchemaExists())) {
// no schema yet, likely first start
return;
}
const latestMigration = await this.#getLatestMigration();
if (latestMigration < 0) {
// no migrations found
return;
}
// the first v0.14.0 migration has ID 11
if (latestMigration > 10) {
// already migrated
return;
}
// add 15s to graceful shutdown timeout, just to be safe
const migrationDelayInMs = env.GRACEFUL_SHUTDOWN_TIMEOUT + 15000;
this.#logDebug("Delaying worker startup due to pending migration", {
latestMigration,
migrationDelayInMs,
});
console.log(`⚠️ detected pending graphile migration`);
console.log(`⚠️ notifying running workers`);
const pgNotify = new PgNotifyService();
await pgNotify.call("trigger:graphile:migrate", { latestMigration });
console.log(`⚠️ delaying worker startup by ${migrationDelayInMs}ms`);
await new Promise((resolve) => setTimeout(resolve, migrationDelayInMs));
}
}
@@ -60,6 +60,11 @@ export class IngestSendEvent {
try {
const deliverAt = this.#calculateDeliverAt(options);
if (!environment.organization.runsEnabled) {
logger.debug("IngestSendEvent: Runs are disabled for this organization", environment);
return;
}
return await $transaction(this.#prismaClient, async (tx) => {
const externalAccount = options?.accountId
? await tx.externalAccount.upsert({
@@ -8,6 +8,7 @@ import { $transaction, prisma } from "~/db.server";
import { workerQueue } from "../worker.server";
import { ResumeRunService } from "./resumeRun.server";
import { createHash } from "node:crypto";
import { logger } from "../logger.server";
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
type RunConnectionsByKey = Awaited<ReturnType<typeof createRunConnections>>;
@@ -36,6 +37,13 @@ export class StartRunService {
}
#runIsStartable(run: FoundRun) {
if (!run.organization.runsEnabled) {
logger.debug("StartRunService: Runs are disabled for this organization", {
organizationId: run.organization.id,
});
return false;
}
const startableStatuses = ["PENDING", "WAITING_ON_CONNECTIONS"] as const;
return startableStatuses.includes(run.status);
}
@@ -64,8 +72,6 @@ export class StartRunService {
await $transaction(
this.#prismaClient,
async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
const counter = await tx.jobCounter.upsert({
where: { jobId: run.jobId },
update: { lastNumber: { increment: 1 } },
@@ -146,6 +152,7 @@ async function findRun(tx: PrismaClientOrTransaction, id: string) {
include: {
queue: true,
environment: true,
organization: true,
version: {
include: {
integrations: {
@@ -55,12 +55,14 @@ export class SecretStore {
}
}
const EncryptedSecretValueSchema = z.object({
export const EncryptedSecretValueSchema = z.object({
nonce: z.string(),
ciphertext: z.string(),
tag: z.string(),
});
export type EncryptedSecretValue = z.infer<typeof EncryptedSecretValueSchema>;
/** This stores secrets in the Postgres Database, encrypted using aes-256-gcm */
class PrismaSecretStore implements SecretStoreProvider {
#prismaClient: PrismaClientOrTransaction;
@@ -181,18 +183,11 @@ class PrismaSecretStore implements SecretStoreProvider {
}
async #decrypt(nonce: string, ciphertext: string, tag: string): Promise<string> {
const decipher = nodeCrypto.createDecipheriv(
"aes-256-gcm",
this.encryptionKey,
Buffer.from(nonce, "hex")
);
decipher.setAuthTag(Buffer.from(tag, "hex"));
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
return await decryptSecret(this.encryptionKey, {
nonce,
ciphertext,
tag,
});
}
async #encrypt(value: string): Promise<{
@@ -200,19 +195,7 @@ class PrismaSecretStore implements SecretStoreProvider {
ciphertext: string;
tag: string;
}> {
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", this.encryptionKey, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
return await encryptSecret(this.encryptionKey, value);
}
}
@@ -234,3 +217,40 @@ export function getSecretStore<
}
}
}
export async function decryptSecret(
encryptionKey: string,
secret: EncryptedSecretValue
): Promise<string> {
const decipher = nodeCrypto.createDecipheriv(
"aes-256-gcm",
encryptionKey,
Buffer.from(secret.nonce, "hex")
);
decipher.setAuthTag(Buffer.from(secret.tag, "hex"));
let decrypted = decipher.update(secret.ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
export async function encryptSecret(
encryptionKey: string,
value: string
): Promise<EncryptedSecretValue> {
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", encryptionKey, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
}
@@ -4,6 +4,7 @@ import { workerQueue } from "../worker.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { createHttpSourceRequest } from "~/utils/createHttpSourceRequest";
import { logger } from "../logger.server";
export class HandleHttpSourceService {
#prismaClient: PrismaClient;
@@ -19,6 +20,7 @@ export class HandleHttpSourceService {
endpoint: true,
environment: true,
secretReference: true,
organization: true,
},
});
@@ -30,6 +32,13 @@ export class HandleHttpSourceService {
return { status: 200 };
}
if (!triggerSource.organization.runsEnabled) {
logger.debug("HandleHttpSourceService: Runs are disabled for this organization", {
organizationId: triggerSource.organization.id,
});
return { status: 404 };
}
if (!triggerSource.interactive) {
const sourceRequest = await createHttpSourceRequest(request);
@@ -37,8 +37,6 @@ export class HandleWebhookRequestService {
const lockId = webhookIdToLockId(webhookEnvironment.webhookId);
await this.#prismaClient.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
const counter = await tx.webhookDeliveryCounter.upsert({
where: { webhookId: webhookEnvironment.id },
update: { lastNumber: { increment: 1 } },
+45 -6
View File
@@ -37,6 +37,10 @@ import { TimeoutDeploymentService } from "~/v3/services/timeoutDeployment.server
import { eventRepository } from "~/v3/eventRepository.server";
import { ExecuteTasksWaitingForDeployService } from "~/v3/services/executeTasksWaitingForDeploy";
import { TriggerScheduledTaskService } from "~/v3/services/triggerScheduledTask.server";
import { PerformTaskAttemptAlertsService } from "~/v3/services/alerts/performTaskAttemptAlerts.server";
import { DeliverAlertService } from "~/v3/services/alerts/deliverAlert.server";
import { PerformDeploymentAlertsService } from "~/v3/services/alerts/performDeploymentAlerts.server";
import { GraphileMigrationHelperService } from "./db/graphileMigrationHelper.server";
const workerCatalog = {
indexEndpoint: z.object({
@@ -136,6 +140,15 @@ const workerCatalog = {
"v3.triggerScheduledTask": z.object({
instanceId: z.string(),
}),
"v3.performTaskAttemptAlerts": z.object({
attemptId: z.string(),
}),
"v3.deliverAlert": z.object({
alertId: z.string(),
}),
"v3.performDeploymentAlerts": z.object({
deploymentId: z.string(),
}),
};
const executionWorkerCatalog = {
@@ -199,9 +212,8 @@ if (env.NODE_ENV === "production") {
}
export async function init() {
// const pgNotify = new PgNotifyService();
// await pgNotify.call("trigger:graphile:migrate", { latestMigration: 10 });
// await new Promise((resolve) => setTimeout(resolve, 10000))
const migrationHelper = new GraphileMigrationHelperService();
await migrationHelper.call();
if (env.WORKER_ENABLED === "true") {
await workerQueue.initialize();
@@ -238,7 +250,7 @@ function getWorkerQueue() {
recurringTasks: {
// Run this every 5 minutes
autoIndexProductionEndpoints: {
pattern: "*/5 * * * *",
match: "*/5 * * * *",
handler: async (payload, job) => {
const service = new RecurringEndpointIndexService();
@@ -247,7 +259,7 @@ function getWorkerQueue() {
},
// Run this every hour
purgeOldIndexings: {
pattern: "0 * * * *",
match: "0 * * * *",
handler: async (payload, job) => {
// Delete indexings that are older than 7 days
await prisma.endpointIndex.deleteMany({
@@ -261,7 +273,7 @@ function getWorkerQueue() {
},
// Run this every hour at the 13 minute mark
purgeOldTaskEvents: {
pattern: "47 * * * *",
match: "47 * * * *",
handler: async (payload, job) => {
await eventRepository.truncateEvents();
},
@@ -533,6 +545,33 @@ function getWorkerQueue() {
return await service.call(payload.instanceId);
},
},
"v3.performTaskAttemptAlerts": {
priority: 0,
maxAttempts: 3,
handler: async (payload, job) => {
const service = new PerformTaskAttemptAlertsService();
return await service.call(payload.attemptId);
},
},
"v3.deliverAlert": {
priority: 0,
maxAttempts: 8,
handler: async (payload, job) => {
const service = new DeliverAlertService();
return await service.call(payload.alertId);
},
},
"v3.performDeploymentAlerts": {
priority: 0,
maxAttempts: 3,
handler: async (payload, job) => {
const service = new PerformDeploymentAlertsService();
return await service.call(payload.deploymentId);
},
},
},
});
}
@@ -1,5 +1,4 @@
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { logger } from "./logger.server";
const environmentSortOrder: RuntimeEnvironmentType[] = [
"DEVELOPMENT",
+15
View File
@@ -325,6 +325,21 @@ export function v3NewEnvironmentVariablesPath(organization: OrgForPath, project:
return `${v3EnvironmentVariablesPath(organization, project)}/new`;
}
export function v3ProjectAlertsPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectPath(organization, project)}/alerts`;
}
export function v3NewProjectAlertPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectAlertsPath(organization, project)}/new`;
}
export function v3NewProjectAlertPathConnectToSlackPath(
organization: OrgForPath,
project: ProjectForPath
) {
return `${v3ProjectAlertsPath(organization, project)}/new/connect-to-slack`;
}
export function v3TestPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -0,0 +1,102 @@
import { ProjectAlertChannel, ProjectAlertType } from "@trigger.dev/database";
import { nanoid } from "nanoid";
import { env } from "~/env.server";
import { findProjectByRef } from "~/models/project.server";
import { encryptSecret } from "~/services/secrets/secretStore.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { BaseService, ServiceValidationError } from "../baseService.server";
export type CreateAlertChannelOptions = {
name: string;
alertTypes: ProjectAlertType[];
deduplicationKey?: string;
channel:
| {
type: "EMAIL";
email: string;
}
| {
type: "WEBHOOK";
url: string;
secret?: string;
}
| {
type: "SLACK";
channelId: string;
channelName: string;
integrationId: string | undefined;
};
};
export class CreateAlertChannelService extends BaseService {
public async call(
projectRef: string,
userId: string,
options: CreateAlertChannelOptions
): Promise<ProjectAlertChannel> {
const project = await findProjectByRef(projectRef, userId);
if (!project) {
throw new ServiceValidationError("Project not found");
}
const existingAlertChannel = options.deduplicationKey
? await this._prisma.projectAlertChannel.findUnique({
where: {
projectId_deduplicationKey: {
projectId: project.id,
deduplicationKey: options.deduplicationKey,
},
},
})
: undefined;
if (existingAlertChannel) {
return await this._prisma.projectAlertChannel.update({
where: { id: existingAlertChannel.id },
data: {
name: options.name,
alertTypes: options.alertTypes,
type: options.channel.type,
properties: await this.#createProperties(options.channel),
},
});
}
const alertChannel = await this._prisma.projectAlertChannel.create({
data: {
friendlyId: generateFriendlyId("alert_channel"),
name: options.name,
alertTypes: options.alertTypes,
projectId: project.id,
type: options.channel.type,
properties: await this.#createProperties(options.channel),
enabled: true,
deduplicationKey: options.deduplicationKey,
userProvidedDeduplicationKey: options.deduplicationKey ? true : false,
},
});
return alertChannel;
}
async #createProperties(channel: CreateAlertChannelOptions["channel"]) {
switch (channel.type) {
case "EMAIL":
return {
email: channel.email,
};
case "WEBHOOK":
return {
url: channel.url,
secret: await encryptSecret(env.ENCRYPTION_KEY, channel.secret ?? nanoid()),
};
case "SLACK":
return {
channelId: channel.channelId,
channelName: channel.channelName,
integrationId: channel.integrationId,
};
}
}
}
@@ -0,0 +1,775 @@
import { TaskRunError, createJsonErrorObject } from "@trigger.dev/core/v3";
import assertNever from "assert-never";
import { subtle } from "crypto";
import { Prisma, PrismaClientOrTransaction, prisma } from "~/db.server";
import { env } from "~/env.server";
import {
ProjectAlertEmailProperties,
ProjectAlertSlackProperties,
ProjectAlertSlackStorage,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
import { sendEmail } from "~/services/email.server";
import { logger } from "~/services/logger.server";
import { decryptSecret } from "~/services/secrets/secretStore.server";
import { workerQueue } from "~/services/worker.server";
import { BaseService } from "../baseService.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
type FoundAlert = Prisma.Result<
typeof prisma.projectAlert,
{
include: {
channel: true;
project: {
include: {
organization: true;
};
};
environment: true;
taskRunAttempt: {
include: {
taskRun: true;
backgroundWorkerTask: true;
backgroundWorker: true;
};
};
workerDeployment: {
include: {
worker: {
include: {
tasks: true;
};
};
};
};
};
},
"findUniqueOrThrow"
>;
export class DeliverAlertService extends BaseService {
public async call(alertId: string) {
const alert = await this._prisma.projectAlert.findUnique({
where: { id: alertId },
include: {
channel: true,
project: {
include: {
organization: true,
},
},
environment: true,
taskRunAttempt: {
include: {
taskRun: true,
backgroundWorkerTask: true,
backgroundWorker: true,
},
},
workerDeployment: {
include: {
worker: {
include: {
tasks: true,
},
},
},
},
},
});
if (!alert) {
return;
}
if (alert.status !== "PENDING") {
return;
}
if (alert.environment.type === "DEVELOPMENT") {
return;
}
switch (alert.channel.type) {
case "EMAIL": {
await this.#sendEmail(alert);
break;
}
case "SLACK": {
await this.#sendSlack(alert);
break;
}
case "WEBHOOK": {
await this.#sendWebhook(alert);
break;
}
default: {
assertNever(alert.channel.type);
}
}
await this._prisma.projectAlert.update({
where: { id: alertId },
data: {
status: "SENT",
},
});
}
async #sendEmail(alert: FoundAlert) {
const emailProperties = ProjectAlertEmailProperties.safeParse(alert.channel.properties);
if (!emailProperties.success) {
logger.error("[DeliverAlert] Failed to parse email properties", {
issues: emailProperties.error.issues,
properties: alert.channel.properties,
});
return;
}
switch (alert.type) {
case "TASK_RUN_ATTEMPT": {
if (alert.taskRunAttempt) {
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
if (!taskRunError.success) {
logger.error("[DeliverAlert] Failed to parse task run error", {
issues: taskRunError.error.issues,
taskAttemptError: alert.taskRunAttempt.error,
});
return;
}
await sendEmail({
email: "alert-attempt",
to: emailProperties.data.email,
taskIdentifier: alert.taskRunAttempt.taskRun.taskIdentifier,
fileName: alert.taskRunAttempt.backgroundWorkerTask.filePath,
exportName: alert.taskRunAttempt.backgroundWorkerTask.exportName,
version: alert.taskRunAttempt.backgroundWorker.version,
environment: alert.environment.slug,
error: createJsonErrorObject(taskRunError.data),
attemptLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`,
});
} else {
logger.error("[DeliverAlert] Task run attempt not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_FAILURE": {
if (alert.workerDeployment) {
const preparedError = DeploymentPresenter.prepareErrorData(
alert.workerDeployment.errorData
);
if (!preparedError) {
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
errorData: alert.workerDeployment.errorData,
});
return;
}
await sendEmail({
email: "alert-deployment-failure",
to: emailProperties.data.email,
version: alert.workerDeployment.version,
environment: alert.environment.slug,
shortCode: alert.workerDeployment.shortCode,
failedAt: alert.workerDeployment.failedAt ?? new Date(),
error: preparedError,
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
});
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_SUCCESS": {
if (alert.workerDeployment) {
await sendEmail({
email: "alert-deployment-success",
to: emailProperties.data.email,
version: alert.workerDeployment.version,
environment: alert.environment.slug,
shortCode: alert.workerDeployment.shortCode,
deployedAt: alert.workerDeployment.deployedAt ?? new Date(),
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
taskCount: alert.workerDeployment.worker?.tasks.length ?? 0,
});
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
}
break;
}
default: {
assertNever(alert.type);
}
}
}
async #sendWebhook(alert: FoundAlert) {
const webhookProperties = ProjectAlertWebhookProperties.safeParse(alert.channel.properties);
if (!webhookProperties.success) {
logger.error("[DeliverAlert] Failed to parse webhook properties", {
issues: webhookProperties.error.issues,
properties: alert.channel.properties,
});
return;
}
switch (alert.type) {
case "TASK_RUN_ATTEMPT": {
if (alert.taskRunAttempt) {
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
if (!taskRunError.success) {
logger.error("[DeliverAlert] Failed to parse task run error", {
issues: taskRunError.error.issues,
taskAttemptError: alert.taskRunAttempt.error,
});
return;
}
const error = createJsonErrorObject(taskRunError.data);
const payload = {
task: {
id: alert.taskRunAttempt.taskRun.taskIdentifier,
filePath: alert.taskRunAttempt.backgroundWorkerTask.filePath,
exportName: alert.taskRunAttempt.backgroundWorkerTask.exportName,
},
attempt: {
id: alert.taskRunAttempt.friendlyId,
number: alert.taskRunAttempt.number,
startedAt: alert.taskRunAttempt.startedAt,
status: alert.taskRunAttempt.status,
},
run: {
id: alert.taskRunAttempt.taskRun.friendlyId,
isTest: alert.taskRunAttempt.taskRun.isTest,
createdAt: alert.taskRunAttempt.taskRun.createdAt,
idempotencyKey: alert.taskRunAttempt.taskRun.idempotencyKey,
},
environment: {
id: alert.environment.id,
type: alert.environment.type,
slug: alert.environment.slug,
},
organization: {
id: alert.project.organizationId,
slug: alert.project.organization.slug,
name: alert.project.organization.title,
},
project: {
id: alert.project.id,
ref: alert.project.externalRef,
slug: alert.project.slug,
name: alert.project.name,
},
error,
};
await this.#deliverWebhook(payload, webhookProperties.data);
} else {
logger.error("[DeliverAlert] Task run attempt not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_FAILURE": {
if (alert.workerDeployment) {
const preparedError = DeploymentPresenter.prepareErrorData(
alert.workerDeployment.errorData
);
if (!preparedError) {
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
errorData: alert.workerDeployment.errorData,
});
return;
}
const payload = {
deployment: {
id: alert.workerDeployment.friendlyId,
status: alert.workerDeployment.status,
version: alert.workerDeployment.version,
shortCode: alert.workerDeployment.shortCode,
failedAt: alert.workerDeployment.failedAt ?? new Date(),
},
environment: {
id: alert.environment.id,
type: alert.environment.type,
slug: alert.environment.slug,
},
organization: {
id: alert.project.organizationId,
slug: alert.project.organization.slug,
name: alert.project.organization.title,
},
project: {
id: alert.project.id,
ref: alert.project.externalRef,
slug: alert.project.slug,
name: alert.project.name,
},
error: preparedError,
};
await this.#deliverWebhook(payload, webhookProperties.data);
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_SUCCESS": {
if (alert.workerDeployment) {
const payload = {
deployment: {
id: alert.workerDeployment.friendlyId,
status: alert.workerDeployment.status,
version: alert.workerDeployment.version,
shortCode: alert.workerDeployment.shortCode,
deployedAt: alert.workerDeployment.deployedAt ?? new Date(),
},
tasks:
alert.workerDeployment.worker?.tasks.map((task) => ({
id: task.slug,
filePath: task.filePath,
exportName: task.exportName,
triggerSource: task.triggerSource,
})) ?? [],
environment: {
id: alert.environment.id,
type: alert.environment.type,
slug: alert.environment.slug,
},
organization: {
id: alert.project.organizationId,
slug: alert.project.organization.slug,
name: alert.project.organization.title,
},
project: {
id: alert.project.id,
ref: alert.project.externalRef,
slug: alert.project.slug,
name: alert.project.name,
},
};
await this.#deliverWebhook(payload, webhookProperties.data);
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
}
break;
}
default: {
assertNever(alert.type);
}
}
}
async #sendSlack(alert: FoundAlert) {
const slackProperties = ProjectAlertSlackProperties.safeParse(alert.channel.properties);
if (!slackProperties.success) {
logger.error("[DeliverAlert] Failed to parse slack properties", {
issues: slackProperties.error.issues,
properties: alert.channel.properties,
});
return;
}
// Get the org integration
const integration = slackProperties.data.integrationId
? await this._prisma.organizationIntegration.findUnique({
where: {
id: slackProperties.data.integrationId,
organizationId: alert.project.organizationId,
},
include: {
tokenReference: true,
},
})
: await this._prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: alert.project.organizationId,
},
orderBy: {
createdAt: "desc",
},
include: {
tokenReference: true,
},
});
if (!integration) {
logger.error("[DeliverAlert] Slack integration not found", {
alert,
});
return;
}
// Get the client
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(
integration,
{ forceBotToken: true }
);
switch (alert.type) {
case "TASK_RUN_ATTEMPT": {
if (alert.taskRunAttempt) {
// Find existing storage by the run ID
const storage = await this._prisma.projectAlertStorage.findFirst({
where: {
alertChannelId: alert.channel.id,
alertType: alert.type,
storageId: alert.taskRunAttempt.taskRunId,
},
});
const storageData = storage
? ProjectAlertSlackStorage.safeParse(storage.storageData)
: undefined;
const thread_ts =
storageData && storageData.success ? storageData.data.message_ts : undefined;
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
if (!taskRunError.success) {
logger.error("[DeliverAlert] Failed to parse task run error", {
issues: taskRunError.error.issues,
taskAttemptError: alert.taskRunAttempt.error,
});
return;
}
const error = createJsonErrorObject(taskRunError.data);
const exportName = alert.taskRunAttempt.backgroundWorkerTask.exportName;
const version = alert.taskRunAttempt.backgroundWorker.version;
const environment = alert.environment.slug;
const taskIdentifier = alert.taskRunAttempt.backgroundWorkerTask.slug;
const timestamp = alert.taskRunAttempt.completedAt ?? new Date();
const runId = alert.taskRunAttempt.taskRun.friendlyId;
const attemptNumber = alert.taskRunAttempt.number;
try {
const message = await client.chat.postMessage({
thread_ts,
channel: slackProperties.data.channelId,
text: `Task error in ${alert.taskRunAttempt.backgroundWorkerTask.exportName} [${alert.taskRunAttempt.backgroundWorker.version}.${alert.environment.slug}]`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `:rotating_light: Error in *${exportName}* _<!date^${Math.round(
timestamp.getTime() / 1000
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `\`\`\`${error.stackTrace ?? error.message}\`\`\``,
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `${runId}.${attemptNumber} | ${taskIdentifier} | ${version}.${environment} | ${alert.project.name}`,
},
],
},
{
type: "divider",
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "Investigate",
},
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`,
},
],
},
],
});
// Upsert the storage
if (message.ts) {
if (storage) {
await this._prisma.projectAlertStorage.update({
where: {
id: storage.id,
},
data: {
storageData: {
message_ts: message.ts,
},
},
});
} else {
await this._prisma.projectAlertStorage.create({
data: {
alertChannelId: alert.channel.id,
alertType: alert.type,
storageId: alert.taskRunAttempt.taskRunId,
storageData: {
message_ts: message.ts,
},
projectId: alert.project.id,
},
});
}
}
} catch (error) {
logger.error("[DeliverAlert] Failed to send slack message", {
error,
alert,
});
throw error;
}
} else {
logger.error("[DeliverAlert] Task run attempt not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_FAILURE": {
if (alert.workerDeployment) {
const preparedError = DeploymentPresenter.prepareErrorData(
alert.workerDeployment.errorData
);
if (!preparedError) {
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
errorData: alert.workerDeployment.errorData,
});
return;
}
const version = alert.workerDeployment.version;
const environment = alert.environment.slug;
const timestamp = alert.workerDeployment.failedAt ?? new Date();
try {
await client.chat.postMessage({
channel: slackProperties.data.channelId,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `:rotating_light: Deployment failed *${version}.${environment}* _<!date^${Math.round(
timestamp.getTime() / 1000
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `\`\`\`${preparedError.stack ?? preparedError.message}\`\`\``,
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`,
},
],
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View Deployment",
},
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
},
],
},
],
});
} catch (error) {
logger.error("[DeliverAlert] Failed to send slack message", {
error,
alert,
});
throw error;
}
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_SUCCESS": {
if (alert.workerDeployment) {
const version = alert.workerDeployment.version;
const environment = alert.environment.slug;
const numberOfTasks = alert.workerDeployment.worker?.tasks.length ?? 0;
const timestamp = alert.workerDeployment.deployedAt ?? new Date();
await client.chat.postMessage({
channel: slackProperties.data.channelId,
text: `Deployment ${alert.workerDeployment.version} [${alert.environment.slug}] succeeded`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `:rocket: Deployed *${version}.${environment}* successfully _<!date^${Math.round(
timestamp.getTime() / 1000
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `${numberOfTasks} tasks | ${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`,
},
],
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View Deployment",
},
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
},
],
},
],
});
return;
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
return;
}
}
}
}
async #deliverWebhook(payload: any, webhook: ProjectAlertWebhookProperties) {
const rawPayload = JSON.stringify(payload);
const hashPayload = Buffer.from(rawPayload, "utf-8");
const secret = await decryptSecret(env.ENCRYPTION_KEY, webhook.secret);
const hmacSecret = Buffer.from(secret, "utf-8");
const key = await subtle.importKey(
"raw",
hmacSecret,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await subtle.sign("HMAC", key, hashPayload);
const signatureHex = Buffer.from(signature).toString("hex");
// Send the webhook to the URL specified in webhook.url
const response = await fetch(webhook.url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-trigger-signature-hmacsha256": signatureHex,
},
body: rawPayload,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
logger.error("[DeliverAlert] Failed to send alert webhook", {
status: response.status,
statusText: response.statusText,
url: webhook.url,
body: payload,
signature,
});
throw new Error(`Failed to send alert webhook to ${webhook.url}`);
}
}
static async enqueue(
alertId: string,
tx: PrismaClientOrTransaction,
options?: { runAt?: Date; queueName?: string }
) {
return await workerQueue.enqueue(
"v3.deliverAlert",
{
alertId,
},
{
tx,
runAt: options?.runAt,
jobKey: `deliverAlert:${alertId}`,
queueName: options?.queueName,
}
);
}
}
@@ -0,0 +1,74 @@
import { ProjectAlertChannel, ProjectAlertType, WorkerDeployment } from "@trigger.dev/database";
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { BaseService } from "../baseService.server";
import { DeliverAlertService } from "./deliverAlert.server";
export class PerformDeploymentAlertsService extends BaseService {
public async call(deploymentId: string) {
const deployment = await this._prisma.workerDeployment.findUnique({
where: { id: deploymentId },
});
if (!deployment) {
return;
}
const alertType =
deployment.status === "DEPLOYED" ? "DEPLOYMENT_SUCCESS" : "DEPLOYMENT_FAILURE";
// Find all the alert channels
const alertChannels = await this._prisma.projectAlertChannel.findMany({
where: {
projectId: deployment.projectId,
alertTypes: {
has: alertType,
},
enabled: true,
},
});
for (const alertChannel of alertChannels) {
await this.#createAndSendAlert(alertChannel, deployment, alertType);
}
}
async #createAndSendAlert(
alertChannel: ProjectAlertChannel,
deployment: WorkerDeployment,
alertType: ProjectAlertType
) {
await $transaction(this._prisma, async (tx) => {
const alert = await this._prisma.projectAlert.create({
data: {
friendlyId: generateFriendlyId("alert"),
channelId: alertChannel.id,
projectId: deployment.projectId,
environmentId: deployment.environmentId,
status: "PENDING",
type: alertType,
workerDeploymentId: deployment.id,
},
});
await DeliverAlertService.enqueue(alert.id, tx, {
queueName: `alert-channel:${alertChannel.id}`,
});
});
}
static async enqueue(deploymentId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
return await workerQueue.enqueue(
"v3.performDeploymentAlerts",
{
deploymentId,
},
{
tx,
runAt,
jobKey: `performDeploymentAlerts:${deploymentId}`,
}
);
}
}
@@ -0,0 +1,82 @@
import { Prisma, ProjectAlertChannel } from "@trigger.dev/database";
import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { BaseService } from "../baseService.server";
import { DeliverAlertService } from "./deliverAlert.server";
type FoundTaskAttempt = Prisma.Result<
typeof prisma.taskRunAttempt,
{ include: { taskRun: true; backgroundWorkerTask: true; runtimeEnvironment: true } },
"findUniqueOrThrow"
>;
export class PerformTaskAttemptAlertsService extends BaseService {
public async call(attemptId: string) {
const taskAttempt = await this._prisma.taskRunAttempt.findUnique({
where: { id: attemptId },
include: {
taskRun: true,
backgroundWorkerTask: true,
runtimeEnvironment: true,
},
});
if (!taskAttempt) {
return;
}
if (taskAttempt.runtimeEnvironment.type === "DEVELOPMENT") {
return;
}
// Find all the alert channels
const alertChannels = await this._prisma.projectAlertChannel.findMany({
where: {
projectId: taskAttempt.taskRun.projectId,
alertTypes: {
has: "TASK_RUN_ATTEMPT",
},
enabled: true,
},
});
for (const alertChannel of alertChannels) {
await this.#createAndSendAlert(alertChannel, taskAttempt);
}
}
async #createAndSendAlert(alertChannel: ProjectAlertChannel, taskAttempt: FoundTaskAttempt) {
await $transaction(this._prisma, async (tx) => {
const alert = await this._prisma.projectAlert.create({
data: {
friendlyId: generateFriendlyId("alert"),
channelId: alertChannel.id,
projectId: taskAttempt.taskRun.projectId,
environmentId: taskAttempt.runtimeEnvironmentId,
status: "PENDING",
type: "TASK_RUN_ATTEMPT",
taskRunAttemptId: taskAttempt.id,
},
});
await DeliverAlertService.enqueue(alert.id, tx, {
queueName: `alert-channel:${alertChannel.id}`,
});
});
}
static async enqueue(attemptId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
return await workerQueue.enqueue(
"v3.performTaskAttemptAlerts",
{
attemptId,
},
{
tx,
runAt,
jobKey: `performTaskAttemptAlerts:${attemptId}`,
}
);
}
}
@@ -19,6 +19,7 @@ import { ResumeTaskRunDependenciesService } from "./resumeTaskRunDependencies.se
import { MAX_TASK_RUN_ATTEMPTS } from "~/consts";
import { CreateCheckpointService } from "./createCheckpoint.server";
import { TaskRun } from "@trigger.dev/database";
import { PerformTaskAttemptAlertsService } from "./alerts/performTaskAttemptAlerts.server";
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
@@ -154,9 +155,13 @@ export class CompleteAttemptService extends BaseService {
},
});
if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) {
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
if (environment.type !== "DEVELOPMENT") {
await PerformTaskAttemptAlertsService.enqueue(taskRunAttempt.id, this._prisma);
}
if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) {
const retryAt = new Date(completion.retry.timestamp);
// Retry the task run
@@ -9,6 +9,7 @@ import { projectPubSub } from "./projectPubSub.server";
import { marqs } from "~/v3/marqs/index.server";
import { logger } from "~/services/logger.server";
import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy";
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
export class CreateDeployedBackgroundWorkerService extends BaseService {
public async call(
@@ -98,6 +99,7 @@ export class CreateDeployedBackgroundWorkerService extends BaseService {
}
await ExecuteTasksWaitingForDeployService.enqueue(backgroundWorker.id, this._prisma);
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
return backgroundWorker;
});
@@ -0,0 +1,35 @@
import { OrganizationIntegration } from "@trigger.dev/database";
import { BaseService } from "./baseService.server";
import { WebClient } from "@slack/web-api";
import { env } from "~/env.server";
import { $transaction } from "~/db.server";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
export class CreateOrgIntegrationService extends BaseService {
public async call(
userId: string,
orgId: string,
serviceName: string,
code: string
): Promise<OrganizationIntegration | undefined> {
// Get the org
const org = await this._prisma.organization.findUnique({
where: {
id: orgId,
members: {
some: {
userId,
},
},
},
});
if (!org) {
throw new Error("Organization not found");
}
return OrgIntegrationRepository.createOrgIntegration(serviceName, code, org);
}
}
@@ -1,3 +1,4 @@
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
import { BaseService } from "./baseService.server";
export class DeploymentIndexFailed extends BaseService {
@@ -22,6 +23,8 @@ export class DeploymentIndexFailed extends BaseService {
},
});
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
return deployment;
}
}
@@ -15,9 +15,10 @@ export class StartDeploymentIndexing extends BaseService {
friendlyId: deploymentId,
},
data: {
imageReference: registryProxy
? registryProxy.rewriteImageReference(body.imageReference)
: body.imageReference,
imageReference:
registryProxy && body.selfHosted !== true
? registryProxy.rewriteImageReference(body.imageReference)
: body.imageReference,
status: "DEPLOYING",
},
});
@@ -1,6 +1,7 @@
import { logger } from "~/services/logger.server";
import { BaseService } from "./baseService.server";
import { workerQueue } from "~/services/worker.server";
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
export class TimeoutDeploymentService extends BaseService {
public async call(id: string, fromStatus: string, errorMessage: string) {
@@ -32,6 +33,8 @@ export class TimeoutDeploymentService extends BaseService {
errorData: { message: errorMessage, name: "TimeoutError" },
},
});
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
}
static async enqueue(
@@ -88,8 +88,6 @@ export class TriggerTaskService extends BaseService {
const lockId = taskIdentifierToLockId(taskId);
const run = await $transaction(this._prisma, async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
const lockedToBackgroundWorker = body.options?.lockToVersion
? await tx.backgroundWorker.findUnique({
where: {
+2 -1
View File
@@ -84,6 +84,7 @@
"@remix-run/serve": "2.1.0",
"@remix-run/server-runtime": "2.1.0",
"@remix-run/v1-meta": "^0.1.3",
"@slack/web-api": "^6.8.1",
"@socket.io/redis-adapter": "^8.3.0",
"@tabler/icons-react": "^2.39.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -115,7 +116,7 @@
"evt": "^2.4.13",
"express": "^4.18.1",
"framer-motion": "^10.12.11",
"graphile-worker": "^0.13.0",
"graphile-worker": "0.16.6",
"highlight.run": "^7.3.4",
"humanize-duration": "^3.27.3",
"intl-parse-accept-language": "^1.0.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

+54 -110
View File
@@ -1,14 +1,8 @@
{
"$schema": "https://mintlify.com/schema.json",
"name": "Trigger.dev",
"openapi": [
"/openapi.yml",
"/v3-openapi.json"
],
"versions": [
"v3 (Developer Preview)",
"v2"
],
"openapi": ["/openapi.yml", "/v3-openapi.json"],
"versions": ["v3 (Developer Preview)", "v2"],
"logo": {
"dark": "/logo/dark.png",
"light": "/logo/light.png",
@@ -48,16 +42,6 @@
"name": "Home"
},
"tabs": [
{
"name": "v3 Developer Preview",
"url": "https://trigger.dev/docs/v3",
"version": "v2"
},
{
"name": "v2",
"url": "https://trigger.dev/docs/documentation",
"version": "v3 (Developer Preview)"
},
{
"name": "Integrations",
"url": "integrations",
@@ -72,6 +56,16 @@
"name": "Examples",
"url": "https://trigger.dev/apis",
"version": "v2"
},
{
"name": "v3 Developer Preview",
"url": "https://trigger.dev/docs/v3",
"version": "v2"
},
{
"name": "v2",
"url": "https://trigger.dev/docs/documentation",
"version": "v3 (Developer Preview)"
}
],
"redirects": [
@@ -96,9 +90,7 @@
{
"group": "",
"version": "v3 (Developer Preview)",
"pages": [
"v3/introduction"
]
"pages": ["v3/introduction"]
},
{
"group": "Getting Started",
@@ -121,12 +113,7 @@
"v3/apikeys",
{
"group": "Task types",
"pages": [
"v3/tasks-regular",
"v3/tasks-scheduled",
"v3/tasks-zod",
"v3/tasks-webhooks"
]
"pages": ["v3/tasks-regular", "v3/tasks-scheduled", "v3/tasks-zod", "v3/tasks-webhooks"]
},
"v3/trigger-config"
]
@@ -134,10 +121,7 @@
{
"group": "Development",
"version": "v3 (Developer Preview)",
"pages": [
"v3/cli-dev",
"v3/run-tests"
]
"pages": ["v3/cli-dev", "v3/run-tests"]
},
{
"group": "Deployment",
@@ -148,9 +132,7 @@
"v3/github-actions",
{
"group": "Deployment integrations",
"pages": [
"v3/vercel-integration"
]
"pages": ["v3/vercel-integration"]
}
]
},
@@ -184,20 +166,30 @@
"v3/automated-tests"
]
},
{
"group": "Dashboard",
"version": "v3 (Developer Preview)",
"pages": [
"v3/dashboard-overview",
"v3/dashboard-runs",
"v3/dashboard-tests",
"v3/dashboard-environment-variables"
]
},
{
"group": "API reference",
"version": "v3 (Developer Preview)",
"pages": [
{
"group": "Runs API",
"pages": [
"v3/management-retrieve-run",
"v3/management-replay-run",
"v3/management-cancel-run"
]
},
{
"group": "Schedules API",
"pages": [
"v3/management-list-schedules",
"v3/management-create-schedule",
"v3/management-retrieve-schedule",
"v3/management-update-schedule",
"v3/management-delete-schedule",
"v3/management-deactivate-schedule",
"v3/management-activate-schedule"
]
},
{
"group": "Functions",
"pages": [
@@ -226,9 +218,7 @@
},
{
"group": "Objects",
"pages": [
"v3/reference-context"
]
"pages": ["v3/reference-context"]
},
{
"group": "CLI",
@@ -242,26 +232,6 @@
"v3/reference-cli-build",
"v3/reference-cli-who-am-i"
]
},
{
"group": "Runs API",
"pages": [
"v3/management-retrieve-run",
"v3/management-replay-run",
"v3/management-cancel-run"
]
},
{
"group": "Schedules API",
"pages": [
"v3/management-list-schedules",
"v3/management-create-schedule",
"v3/management-retrieve-schedule",
"v3/management-update-schedule",
"v3/management-delete-schedule",
"v3/management-deactivate-schedule",
"v3/management-activate-schedule"
]
}
]
},
@@ -277,11 +247,7 @@
{
"group": "Open source",
"version": "v3 (Developer Preview)",
"pages": [
"v3/github-repo",
"v3/open-source-self-hosting",
"v3/open-source-contributing"
]
"pages": ["v3/github-repo", "v3/open-source-self-hosting", "v3/open-source-contributing"]
},
{
"group": "Help",
@@ -459,6 +425,7 @@
},
{
"group": "Overview",
"version": "v2",
"pages": [
"integrations/introduction",
{
@@ -479,13 +446,11 @@
},
{
"group": "Integrations",
"version": "v2",
"pages": [
{
"group": "Airtable",
"pages": [
"integrations/apis/airtable",
"integrations/apis/airtable-tasks"
]
"pages": ["integrations/apis/airtable", "integrations/apis/airtable-tasks"]
},
{
"group": "GitHub",
@@ -511,25 +476,16 @@
},
{
"group": "Plain",
"pages": [
"integrations/apis/plain",
"integrations/apis/plain-tasks"
]
"pages": ["integrations/apis/plain", "integrations/apis/plain-tasks"]
},
"integrations/apis/replicate",
{
"group": "SendGrid",
"pages": [
"integrations/apis/sendgrid",
"integrations/apis/sendgrid-tasks"
]
"pages": ["integrations/apis/sendgrid", "integrations/apis/sendgrid-tasks"]
},
{
"group": "Resend",
"pages": [
"integrations/apis/resend",
"integrations/apis/resend-tasks"
]
"pages": ["integrations/apis/resend", "integrations/apis/resend-tasks"]
},
{
"group": "Shopify",
@@ -541,10 +497,7 @@
},
{
"group": "Slack",
"pages": [
"integrations/apis/slack",
"integrations/apis/slack-tasks"
]
"pages": ["integrations/apis/slack", "integrations/apis/slack-tasks"]
},
"integrations/apis/stripe",
{
@@ -560,6 +513,7 @@
},
{
"group": "SDK",
"version": "v2",
"pages": [
"sdk/introduction",
{
@@ -569,9 +523,7 @@
"sdk/triggerclient/constructor",
{
"group": "Instance properties",
"pages": [
"sdk/triggerclient/store"
]
"pages": ["sdk/triggerclient/store"]
},
{
"group": "Instance methods",
@@ -634,10 +586,7 @@
"sdk/dynamictrigger/constructor",
{
"group": "Instance methods",
"pages": [
"sdk/dynamictrigger/register",
"sdk/dynamictrigger/unregister"
]
"pages": ["sdk/dynamictrigger/register", "sdk/dynamictrigger/unregister"]
}
]
},
@@ -648,10 +597,7 @@
"sdk/dynamicschedule/constructor",
{
"group": "Instance methods",
"pages": [
"sdk/dynamicschedule/register",
"sdk/dynamicschedule/unregister"
]
"pages": ["sdk/dynamicschedule/register", "sdk/dynamicschedule/unregister"]
}
]
},
@@ -663,12 +609,12 @@
},
{
"group": "HTTP Reference",
"pages": [
"sdk/api-reference/events/create-an-event"
]
"version": "v2",
"pages": ["sdk/api-reference/events/create-an-event"]
},
{
"group": "React SDK",
"version": "v2",
"pages": [
"sdk/react/introduction",
"sdk/react/triggerprovider",
@@ -680,9 +626,7 @@
{
"group": "Overview",
"version": "v2",
"pages": [
"examples/introduction"
]
"pages": ["examples/introduction"]
}
],
"footerSocials": {
@@ -690,4 +634,4 @@
"github": "https://github.com/triggerdotdev",
"linkedin": "https://www.linkedin.com/company/triggerdotdev"
}
}
}
+19 -19
View File
@@ -3,24 +3,24 @@ title: "Feature matrix"
description: "What features are currently available in the Developer Preview"
---
| Feature | Description | Status |
| ----------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
| [Regular tasks](/v3/tasks-regular) | A task that can be triggered from anywhere | ✅ |
| [Triggering](/v3/triggering) | Triggering and batch triggering tasks | ✅ |
| [Testing from the dashboard](/v3/run-tests) | Test your tasks from the dashboard | ✅ |
| [Queues and concurrency controls](/v3/queue-concurrency) | Queues and concurrency controls | ✅ |
| [Per-tenant queuing](/v3/queue-concurrency#concurrency-keys-and-per-tenant-queuing) | Separate queues for each of your users | ✅ |
| [Reattempts and retrying](/v3/errors-retrying) | Write reliable tasks using retries | ✅ |
| [Atomic versioning](/v3/versioning) | Each deploy creates a new version | ✅ |
| [Deploy via CLI](/v3/cli-deploy) | Deploy from the command line | ✅ |
| [Deploy via GitHub Actions](/v3/github-actions) | Deploy using GitHub Actions | ✅ |
| [Scheduled tasks](/v3/tasks-scheduled) | A task that can be triggered on a schedule | ✅ |
| [Zod tasks](/v3/tasks-zod) | Define tasks using Zod schemas | |
| [Webhook tasks](/v3/tasks-webhooks) | A task that can be triggered by a webhook | ⏳ |
| Full text search of runs | Find a run by searching the payload and output | ⏳ |
| Logs view with search | All logs view with filtering and full text search | ⏳ |
| Alerts | Add alerts in the UI for errors and queue backlogs | ⏳ |
| Notifications | Send data to your web app from a run | ⏳ |
| Rollbacks | Easily rollback changes when errors happen | ⏳ |
| Feature | Description | Status |
| ----------------------------------------------------------------------------------- | ------------------------------------------------- | ------ |
| [Regular tasks](/v3/tasks-regular) | A task that can be triggered from anywhere | ✅ |
| [Triggering](/v3/triggering) | Triggering and batch triggering tasks | ✅ |
| [Testing from the dashboard](/v3/run-tests) | Test your tasks from the dashboard | ✅ |
| [Queues and concurrency controls](/v3/queue-concurrency) | Queues and concurrency controls | ✅ |
| [Per-tenant queuing](/v3/queue-concurrency#concurrency-keys-and-per-tenant-queuing) | Separate queues for each of your users | ✅ |
| [Reattempts and retrying](/v3/errors-retrying) | Write reliable tasks using retries | ✅ |
| [Atomic versioning](/v3/versioning) | Each deploy creates a new version | ✅ |
| [Deploy via CLI](/v3/cli-deploy) | Deploy from the command line | ✅ |
| [Deploy via GitHub Actions](/v3/github-actions) | Deploy using GitHub Actions | ✅ |
| Alerts | Add alerts in the UI for errors and deploys | ✅ |
| [Scheduled tasks](/v3/tasks-scheduled) | A task that can be triggered on a schedule | |
| [Zod tasks](/v3/tasks-zod) | Define tasks using Zod schemas | ⏳ |
| [Webhook tasks](/v3/tasks-webhooks) | A task that can be triggered by a webhook | ⏳ |
| Full text search of runs | Find a run by searching the payload and output | ⏳ |
| Logs view with search | All logs view with filtering and full text search | ⏳ |
| Notifications | Send data to your web app from a run | ⏳ |
| Rollbacks | Easily rollback changes when errors happen | ⏳ |
[Let us know](https://trigger.dev/discord) what we should prioritize and what we are missing.
+18 -4
View File
@@ -5,7 +5,10 @@ description: "You can easily deploy your tasks with GitHub actions."
This simple GitHub action file will deploy you Trigger.dev tasks when new code is pushed to the `main` branch and the `trigger` directory has changes in it.
<Warning>The deploy step will fail if any version mismatches are detected. Please see the [version pinning](/v3/github-actions#version-pinning) section for more details.</Warning>
<Warning>
The deploy step will fail if any version mismatches are detected. Please see the [version
pinning](/v3/github-actions#version-pinning) section for more details.
</Warning>
<CodeGroup>
@@ -30,7 +33,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Install dependencies
run: npm install
@@ -41,7 +44,6 @@ jobs:
npx trigger.dev@beta deploy
```
```yaml .github/workflows/release-trigger-staging.yml
name: Deploy to Trigger.dev (staging)
@@ -60,7 +62,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Install dependencies
run: npm install
@@ -70,6 +72,7 @@ jobs:
run: |
npx trigger.dev@beta deploy --env staging
```
</CodeGroup>
If you already have a GitHub action file, you can just add the final step "🚀 Deploy Trigger.dev" to your existing file.
@@ -78,6 +81,17 @@ You need to add the `TRIGGER_ACCESS_TOKEN` secret to your repository. You can cr
To set it in GitHub go to your repository, click on "Settings", "Secrets and variables" and then "Actions". Add a new secret with the name `TRIGGER_ACCESS_TOKEN` and use the value of your access token.
<Accordion title="How to add TRIGGER_ACCESS_TOKEN in GitHub">
1. Go to your repository on GitHub.
2. Click on "Settings".
3. Click on "Secrets and variables" -> "Actions"
4. Click on "New repository secret".
5. Add the name `TRIGGER_ACCESS_TOKEN` and the value of your access token.
![Add TRIGGER_ACCESS_TOKEN in GitHub](/images/v3/github-access-token.png)
</Accordion>
## Version pinning
The CLI and `@trigger.dev/*` package versions need to be in sync, otherwise there will be errors and unpredictable behavior. Hence, the `deploy` command will automatically fail during CI on any version mismatches.
+604
View File
@@ -0,0 +1,604 @@
import { z } from "zod";
import * as v from "valibot";
import { wrap } from "@typeschema/valibot";
export type ParserZodEsque<TInput, TParsedInput> = {
_input: TInput;
_output: TParsedInput;
};
export type ParserMyZodEsque<TInput> = {
parse: (input: any) => TInput;
};
export type ParserSuperstructEsque<TInput> = {
create: (input: unknown) => TInput;
};
export type ParserCustomValidatorEsque<TInput> = (input: unknown) => Promise<TInput> | TInput;
export type ParserYupEsque<TInput> = {
validateSync: (input: unknown) => TInput;
};
export type ParserScaleEsque<TInput> = {
assert(value: unknown): asserts value is TInput;
};
export type ParserWithoutInput<TInput> =
| ParserCustomValidatorEsque<TInput>
| ParserMyZodEsque<TInput>
| ParserScaleEsque<TInput>
| ParserSuperstructEsque<TInput>
| ParserYupEsque<TInput>;
export type ParserWithInputOutput<TInput, TParsedInput> = ParserZodEsque<TInput, TParsedInput>;
export type Parser = ParserWithInputOutput<any, any> | ParserWithoutInput<any>;
export type inferParser<TParser extends Parser> = TParser extends ParserWithInputOutput<
infer $TIn,
infer $TOut
>
? {
in: $TIn;
out: $TOut;
}
: TParser extends ParserWithoutInput<infer $InOut>
? {
in: $InOut;
out: $InOut;
}
: never;
export type Simplify<TType> = TType extends any[] | Date ? TType : { [K in keyof TType]: TType[K] };
export type TriggerResult = {
id: string;
};
export type TaskRunResult<TOutput = any> =
| {
ok: true;
id: string;
output: TOutput;
}
| {
ok: false;
id: string;
error: unknown;
};
export type RunMetadata = {
run: string;
};
export type inferContext<TContextBuilder extends AnyContextBuilder> =
TContextBuilder extends ContextBuilder<infer TContext, infer TContextOverrides>
? TContext extends UnsetMarker
? unknown
: TContextOverrides extends UnsetMarker
? Simplify<TContext>
: Simplify<Overwrite<TContext, TContextOverrides>>
: never;
export type RunFnParams<TPayload, TContext extends AnyContextBuilder> = {
/** Metadata about the task, run, attempt, queue, environment, organization, project and batch. */
meta: RunMetadata;
/** Context added by task middleware */
ctx: inferContext<TContext>;
payload: TPayload;
};
/**
* See https://github.com/microsoft/TypeScript/issues/41966#issuecomment-758187996
* Fixes issues with iterating over keys of objects with index signatures.
* Without this, iterations over keys of objects with index signatures will lose
* type information about the keys and only the index signature will remain.
* @internal
*/
export type WithoutIndexSignature<TObj> = {
[K in keyof TObj as string extends K ? never : number extends K ? never : K]: TObj[K];
};
/**
* @internal
* Overwrite properties in `TType` with properties in `TWith`
* Only overwrites properties when the type to be overwritten
* is an object. Otherwise it will just use the type from `TWith`.
*/
export type Overwrite<TType, TWith> = TWith extends any
? TType extends object
? {
[K in
| keyof WithoutIndexSignature<TType>
| keyof WithoutIndexSignature<TWith>]: K extends keyof TWith // Exclude index signature from keys
? TWith[K]
: K extends keyof TType
? TType[K]
: never;
} & (string extends keyof TWith // Handle cases with an index signature
? { [key: string]: TWith[string] }
: number extends keyof TWith
? { [key: number]: TWith[number] }
: // eslint-disable-next-line @typescript-eslint/ban-types
{})
: TWith
: never;
/** @internal */
export const contextMiddlewareMarker = "contextMiddlewareMarker" as "contextMiddlewareMarker" & {
__brand: "contextMiddlewareMarker";
};
type ContextMiddlewareMarker = typeof contextMiddlewareMarker;
interface ContextMiddlewareResultBase {
/**
* All middlewares should pass through their `next()`'s output.
* Requiring this marker makes sure that can't be forgotten at compile-time.
*/
readonly marker: ContextMiddlewareMarker;
}
interface ContextMiddlewareOKResult<_TContextOverride> extends ContextMiddlewareResultBase {
ok: true;
data: unknown;
}
interface ContextMiddlewareErrorResult<_TContextOverride> extends ContextMiddlewareResultBase {
ok: false;
error: Error; // should be our error
}
/**
* @internal
*/
export type ContextMiddlewareResult<_TContextOverride> =
| ContextMiddlewareErrorResult<_TContextOverride>
| ContextMiddlewareOKResult<_TContextOverride>;
export type ContextMiddlewareFunction<TContext, TContextOverridesIn, $ContextOverridesOut> = {
(opts: {
ctx: Simplify<Overwrite<TContext, TContextOverridesIn>>;
meta: RunMetadata;
next: {
(): Promise<ContextMiddlewareResult<TContextOverridesIn>>;
<$ContextOverride>(ctx: $ContextOverride): Promise<ContextMiddlewareResult<$ContextOverride>>;
};
}): Promise<ContextMiddlewareResult<$ContextOverridesOut>>;
};
export const unsetMarker = Symbol("unsetMarker");
export type UnsetMarker = typeof unsetMarker;
export interface ContextBuilder<TContext extends object, TContextOverrides> {
use<$ContextOverridesOut>(
fn: ContextMiddlewareFunction<TContext, TContextOverrides, $ContextOverridesOut>
): ContextBuilder<TContext, Overwrite<TContextOverrides, $ContextOverridesOut>>;
}
export type AnyContextBuilder = ContextBuilder<any, any>;
export function createContext<TContext extends object>(
initialContext?: TContext
): ContextBuilder<TContext, UnsetMarker> {
const builder: AnyContextBuilder = {
use(middlewareFn) {
return {} as AnyContextBuilder;
},
};
return builder;
}
const contextBuilder = createContext({ foo: "bar" });
const context = contextBuilder
.use((opts) => {
return opts.next({
baz: "whatever",
});
})
.use((opts) => {
return opts.next({
db: {
find: async (id: string) => {
return "hello";
},
},
});
});
type ctx = inferContext<typeof context>;
const contextBuilder2 = createContext();
type ctx2 = inferContext<typeof contextBuilder2>;
const contextBuilder3 = createContext({ bar: "baz" });
type ctx3 = inferContext<typeof contextBuilder3>;
const contextBuilder4 = createContext().use((opts) => {
return opts.next({
hello: "world",
});
});
type ctx4 = inferContext<typeof contextBuilder4>;
export type TaskOptions<
TOutput,
TContext extends AnyContextBuilder,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
> = {
/** An id for your task. This must be unique inside your project and not change between versions. */
id: TIdentifier;
schema?: TParser;
context?: TContext;
/** This gets called when a task is triggered. It's where you put the code you want to execute.
*
* @param payload - The payload that is passed to your task when it's triggered. This must be JSON serializable.
* @param params - Metadata about the run.
*/
run: (params: Simplify<RunFnParams<inferParserOut<TParser>, TContext>>) => Promise<TOutput>;
};
export interface Task<
TOutput,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
> {
/**
* The id of the task.
*/
id: TIdentifier;
/**
* Trigger a task with the given payload, and continue without waiting for the result. If you want to wait for the result, use `triggerAndWait`. Returns the id of the triggered task run.
* @param payload
* @param options
* @returns TriggerResult
* - `id` - The id of the triggered task run.
*/
trigger: (
payload: Simplify<inferParserIn<TParser, any>>,
options?: TriggerTaskOptions
) => Promise<TriggerResult>;
/**
* Trigger a task with the given payload, and wait for the result. Returns the result of the task run
* @param payload
* @param options - Options for the task run
* @returns TaskRunResult
* @example
* ```
* const result = await task.triggerAndWait({ foo: "bar" });
*
* if (result.ok) {
* console.log(result.output);
* } else {
* console.error(result.error);
* }
* ```
*/
triggerAndWait: (
payload: Simplify<inferParserIn<TParser, any>>,
options?: TriggerTaskOptions
) => Promise<TaskRunResult<TOutput>>;
}
export type AnyTask = Task<any, string, any>;
type inferParserIn<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
? inferParser<TParser>["in"]
: TDefault;
type inferParserOut<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
? inferParser<TParser>["out"]
: TDefault;
export type TaskPayloadIn<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
? inferParserIn<TParser>
: never;
export type TaskPayloadOut<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
? inferParserOut<TParser>
: never;
export type TaskOutput<TTask extends AnyTask> = TTask extends Task<infer TOutput, string, any>
? TOutput
: never;
export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<any, infer TIdentifier, any>
? TIdentifier
: never;
export type TaskTypes<TTask extends AnyTask> = TTask extends Task<
infer TOutput,
infer TIdentifier,
infer TParser
>
? {
id: TIdentifier;
payloadIn: inferParserIn<TParser>;
payloadOut: inferParserOut<TParser>;
output: TOutput;
}
: never;
export type TriggerTaskOptions = {
idempotencyKey?: string;
maxAttempts?: number;
startAt?: Date;
startAfter?: number;
concurrencyKey?: string;
};
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export function task<
TOutput,
TContext extends AnyContextBuilder,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
>(
options: TaskOptions<TOutput, TContext, TIdentifier, TParser>
): Task<TOutput, TIdentifier, TParser> {
return createTask(options);
}
export function createTask<
TOutput,
TContext extends AnyContextBuilder,
TIndentifier extends string,
TParser extends Parser | undefined = undefined,
>(
params: TaskOptions<TOutput, TContext, TIndentifier, TParser>
): Task<TOutput, TIndentifier, TParser> {
const task: Task<TOutput, TIndentifier, TParser> = {
id: params.id,
trigger: async (payload, options) => {
return {
id: "run_1234",
};
},
triggerAndWait: async (payload, options) => {
const output = await params.run({
meta: { run: "run_1234" },
payload: payload as unknown as inferParserOut<TParser>, // Actually do the parsing
ctx: {} as inferContext<TContext>,
});
return {
ok: true,
id: "run_1234",
output,
};
},
};
return task;
}
export interface TaskLibraryRecord {
[key: string]: AnyTask | TaskLibraryRecord;
}
export interface TaskLibrary<TRecord extends TaskLibraryRecord> {
_def: { record: TRecord };
}
export type AnyTaskLibrary = TaskLibrary<any>;
export type CreateTaskLibraryOptions = {
[key: string]: AnyTask | AnyTaskLibrary | CreateTaskLibraryOptions;
};
export type DecorateCreateTaskLibraryOptions<TTaskLibraryOptions extends CreateTaskLibraryOptions> =
{
[K in keyof TTaskLibraryOptions]: TTaskLibraryOptions[K] extends infer $Value
? $Value extends AnyTask
? $Value
: $Value extends TaskLibrary<infer TRecord>
? TRecord
: $Value extends CreateTaskLibraryOptions
? DecorateCreateTaskLibraryOptions<$Value>
: never
: never;
};
function taskLibrary<TInput extends CreateTaskLibraryOptions>(
input: TInput
): TaskLibrary<DecorateCreateTaskLibraryOptions<TInput>>;
function taskLibrary<TInput extends TaskLibraryRecord>(input: TInput): TaskLibrary<TInput>;
function taskLibrary(input: TaskLibraryRecord | CreateTaskLibraryOptions) {
// TODO: reserved words
return {
_def: {
record: input,
},
};
}
// ======== client side
type DecorateTask<TTask extends AnyTask> = {
trigger: (id: TaskIdentifier<TTask>, payload: TaskPayloadIn<TTask>) => Promise<{ id: string }>;
};
type DecoratedTaskLibraryRecord<
TTaskLibrary extends AnyTaskLibrary,
TRecord extends TaskLibraryRecord,
> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TaskLibraryRecord
? DecoratedTaskLibraryRecord<TTaskLibrary, $Value>
: $Value extends AnyTask
? DecorateTask<$Value>
: never
: never;
};
export type inferTaskLibraryClient<TTaskLibrary extends AnyTaskLibrary> =
DecoratedTaskLibraryRecord<TTaskLibrary, TTaskLibrary["_def"]["record"]>;
export type CreateTriggerClient<TTaskLibrary extends AnyTaskLibrary> = {
lib: inferTaskLibraryClient<TTaskLibrary>;
runs: {
retrieve: (id: string) => Promise<{ status: boolean }>;
};
};
export type CreateTriggerClientOptions = {
secretKey?: string;
};
export function createTriggerClient<TTaskLibrary extends AnyTaskLibrary>(
options?: CreateTriggerClientOptions
): CreateTriggerClient<TTaskLibrary> {
return {} as CreateTriggerClient<TTaskLibrary>;
}
// trigger/my-tasks.ts
const taskOne = task({
id: "task-1",
run: async () => {
const handle = await taskTwo.trigger({ url: "https://trigger.dev" });
const result = await taskTwo.triggerAndWait({ url: "https://trigger.dev" });
return "foo-bar";
},
});
const taskTwo = task({
id: "task-2",
async run(params) {
return {
hello: "world",
payload: params.payload.other,
};
},
});
const userTaskOne = task({
id: "user/task-1",
context: contextBuilder4,
run: async (params) => {
return "foo-bar";
},
});
const userTaskTwo = task({
id: "user/task-2",
context: contextBuilder3,
run: async (params) => {
return "foo-bar";
},
});
const zodTaskOne = task({
id: "zod/task-1",
context: contextBuilder,
schema: z.object({ foo: z.string() }),
run: async (params) => {},
});
const zodTaskTwo = task({
id: "zod/task-2",
schema: z.object({ foo: z.string(), isAdmin: z.boolean().default(false) }),
context: contextBuilder2,
run: async (params) => {
console.log(params.payload.foo, params.meta.run);
},
});
const valibotTaskOne = task({
id: "valibot/task-1",
schema: wrap(
v.object({
foo: v.string(),
})
),
run: async (params) => {
await zodTaskOne.trigger({ foo: "bar" });
await zodTaskTwo.trigger({ foo: "bar" });
await valibotTaskTwo.trigger({ foo: "bar" });
},
});
const valibotTaskTwo = task({
id: "valibot/task-2",
schema: wrap(
v.object({
foo: v.string(),
isAdmin: v.optional(v.boolean(), true),
})
),
run: async (params) => {
await valibotTaskOne.trigger({ foo: "bar" });
},
});
// in trigger/lib.ts
const myTaskLibrary = taskLibrary({
myTasks: { taskOne, taskTwo },
});
const userTaskLibrary = taskLibrary({
userTaskOne,
userTaskTwo,
});
const zodTaskLibrary = taskLibrary({
zodTaskOne,
zodTaskTwo,
});
const valibotTaskLibrary = taskLibrary({
valibotTaskOne,
valibotTaskTwo,
});
export const library = taskLibrary({
foo: myTaskLibrary,
bar: userTaskLibrary,
zod: zodTaskLibrary,
valibot: valibotTaskLibrary,
});
// Export the library type
export type Library = typeof library;
// Now on the client
const client = createTriggerClient<Library>({
secretKey: "tr_dev_1234",
});
client.runs.retrieve("run_12343"); // Call regular API client calls
// Tasks are now available under lib
client.lib.foo.myTasks.taskOne.trigger("task-1", { hello: "world" });
client.lib.bar.userTaskOne.trigger("user/task-1", { userId: "user_123" });
client.lib.bar.userTaskTwo.trigger("user/task-2", {
userId: "user_123",
isAdmin: true,
});
client.lib.bar.userTaskTwo.trigger("user/task-2", {
userId: "user_123",
isAdmin: false,
});
client.lib.zod.zodTaskOne.trigger("zod/task-1", { foo: "bar" });
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar" });
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar", isAdmin: false });
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar" });
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", {
foo: "bar",
isAdmin: true,
});
+444
View File
@@ -0,0 +1,444 @@
import { z } from "zod";
import * as v from "valibot";
import { wrap } from "@typeschema/valibot";
export type ParserZodEsque<TInput, TParsedInput> = {
_input: TInput;
_output: TParsedInput;
};
export type ParserMyZodEsque<TInput> = {
parse: (input: any) => TInput;
};
export type ParserSuperstructEsque<TInput> = {
create: (input: unknown) => TInput;
};
export type ParserCustomValidatorEsque<TInput> = (input: unknown) => Promise<TInput> | TInput;
export type ParserYupEsque<TInput> = {
validateSync: (input: unknown) => TInput;
};
export type ParserScaleEsque<TInput> = {
assert(value: unknown): asserts value is TInput;
};
export type ParserWithoutInput<TInput> =
| ParserCustomValidatorEsque<TInput>
| ParserMyZodEsque<TInput>
| ParserScaleEsque<TInput>
| ParserSuperstructEsque<TInput>
| ParserYupEsque<TInput>;
export type ParserWithInputOutput<TInput, TParsedInput> = ParserZodEsque<TInput, TParsedInput>;
export type Parser = ParserWithInputOutput<any, any> | ParserWithoutInput<any>;
export type inferParser<TParser extends Parser> = TParser extends ParserWithInputOutput<
infer $TIn,
infer $TOut
>
? {
in: $TIn;
out: $TOut;
}
: TParser extends ParserWithoutInput<infer $InOut>
? {
in: $InOut;
out: $InOut;
}
: never;
export type Simplify<TType> = TType extends any[] | Date ? TType : { [K in keyof TType]: TType[K] };
export type TriggerResult = {
id: string;
};
export type TaskRunResult<TOutput = any> =
| {
ok: true;
id: string;
output: TOutput;
}
| {
ok: false;
id: string;
error: unknown;
};
export type RunMetadata = {
run: string;
};
export type RunFnParams<TPayload, TContext extends object> = {
/** Metadata about the task, run, attempt, queue, environment, organization, project and batch. */
meta: RunMetadata;
/** Context added by task middleware */
ctx: TContext;
payload: TPayload;
};
export type TaskOptions<
TOutput,
TContext extends object,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
> = {
/** An id for your task. This must be unique inside your project and not change between versions. */
id: TIdentifier;
schema?: TParser;
/** This gets called when a task is triggered. It's where you put the code you want to execute.
*
* @param payload - The payload that is passed to your task when it's triggered. This must be JSON serializable.
* @param params - Metadata about the run.
*/
run: (params: Simplify<RunFnParams<inferParserOut<TParser>, TContext>>) => Promise<TOutput>;
};
export interface Task<
TOutput,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
> {
/**
* The id of the task.
*/
id: TIdentifier;
/**
* Trigger a task with the given payload, and continue without waiting for the result. If you want to wait for the result, use `triggerAndWait`. Returns the id of the triggered task run.
* @param payload
* @param options
* @returns TriggerResult
* - `id` - The id of the triggered task run.
*/
trigger: (
payload: Simplify<inferParserIn<TParser, any>>,
options?: TriggerTaskOptions
) => Promise<TriggerResult>;
/**
* Trigger a task with the given payload, and wait for the result. Returns the result of the task run
* @param payload
* @param options - Options for the task run
* @returns TaskRunResult
* @example
* ```
* const result = await task.triggerAndWait({ foo: "bar" });
*
* if (result.ok) {
* console.log(result.output);
* } else {
* console.error(result.error);
* }
* ```
*/
triggerAndWait: (
payload: Simplify<inferParserIn<TParser, any>>,
options?: TriggerTaskOptions
) => Promise<TaskRunResult<TOutput>>;
}
export type AnyTask = Task<any, string, any>;
type inferParserIn<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
? inferParser<TParser>["in"]
: TDefault;
type inferParserOut<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
? inferParser<TParser>["out"]
: TDefault;
export type TaskPayloadIn<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
? inferParserIn<TParser>
: never;
export type TaskPayloadOut<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
? inferParserOut<TParser>
: never;
export type TaskOutput<TTask extends AnyTask> = TTask extends Task<infer TOutput, string, any>
? TOutput
: never;
export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<any, infer TIdentifier, any>
? TIdentifier
: never;
export type TaskTypes<TTask extends AnyTask> = TTask extends Task<
infer TOutput,
infer TIdentifier,
infer TParser
>
? {
id: TIdentifier;
payloadIn: inferParserIn<TParser>;
payloadOut: inferParserOut<TParser>;
output: TOutput;
}
: never;
export type TriggerTaskOptions = {
idempotencyKey?: string;
maxAttempts?: number;
startAt?: Date;
startAfter?: number;
concurrencyKey?: string;
};
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export function task<
TOutput,
TContext extends object,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
>(
options: TaskOptions<TOutput, TContext, TIdentifier, TParser>
): Task<TOutput, TIdentifier, TParser> {
return createTask(options);
}
export function createTask<
TOutput,
TContext extends object,
TIndentifier extends string,
TParser extends Parser | undefined = undefined,
>(
params: TaskOptions<TOutput, TContext, TIndentifier, TParser>
): Task<TOutput, TIndentifier, TParser> {
const task: Task<TOutput, TIndentifier, TParser> = {
id: params.id,
trigger: async (payload, options) => {
return {
id: "run_1234",
};
},
triggerAndWait: async (payload, options) => {
const output = await params.run({
meta: { run: "run_1234" },
payload: payload as unknown as inferParserOut<TParser>, // Actually do the parsing
ctx: {} as TContext,
});
return {
ok: true,
id: "run_1234",
output,
};
},
};
return task;
}
export interface TaskLibraryRecord {
[key: string]: AnyTask | TaskLibraryRecord;
}
export interface TaskLibrary<TRecord extends TaskLibraryRecord> {
_def: { record: TRecord };
}
export type AnyTaskLibrary = TaskLibrary<any>;
export type CreateTaskLibraryOptions = {
[key: string]: AnyTask | AnyTaskLibrary | CreateTaskLibraryOptions;
};
export type DecorateCreateTaskLibraryOptions<TTaskLibraryOptions extends CreateTaskLibraryOptions> =
{
[K in keyof TTaskLibraryOptions]: TTaskLibraryOptions[K] extends infer $Value
? $Value extends AnyTask
? $Value
: $Value extends TaskLibrary<infer TRecord>
? TRecord
: $Value extends CreateTaskLibraryOptions
? DecorateCreateTaskLibraryOptions<$Value>
: never
: never;
};
function taskLibrary<TInput extends CreateTaskLibraryOptions>(
input: TInput
): TaskLibrary<DecorateCreateTaskLibraryOptions<TInput>>;
function taskLibrary<TInput extends TaskLibraryRecord>(input: TInput): TaskLibrary<TInput>;
function taskLibrary(input: TaskLibraryRecord | CreateTaskLibraryOptions) {
// TODO: reserved words
return {
_def: {
record: input,
},
};
}
// ======== client side
type DecorateTask<TTask extends AnyTask> = {
trigger: (id: TaskIdentifier<TTask>, payload: TaskPayloadIn<TTask>) => Promise<{ id: string }>;
};
type DecoratedTaskLibraryRecord<
TTaskLibrary extends AnyTaskLibrary,
TRecord extends TaskLibraryRecord,
> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TaskLibraryRecord
? DecoratedTaskLibraryRecord<TTaskLibrary, $Value>
: $Value extends AnyTask
? DecorateTask<$Value>
: never
: never;
};
export type inferTaskLibraryClient<TTaskLibrary extends AnyTaskLibrary> =
DecoratedTaskLibraryRecord<TTaskLibrary, TTaskLibrary["_def"]["record"]>;
export type CreateTriggerClient<TTaskLibrary extends AnyTaskLibrary> = {
lib: inferTaskLibraryClient<TTaskLibrary>;
runs: {
retrieve: (id: string) => Promise<{ status: boolean }>;
};
};
export type CreateTriggerClientOptions = {
secretKey?: string;
};
export function createTriggerClient<TTaskLibrary extends AnyTaskLibrary>(
options?: CreateTriggerClientOptions
): CreateTriggerClient<TTaskLibrary> {
return {} as CreateTriggerClient<TTaskLibrary>;
}
// trigger/my-tasks.ts
const taskOne = task({
id: "task-1",
run: async () => {
const handle = await taskTwo.trigger({ url: "https://trigger.dev" });
const result = await taskTwo.triggerAndWait({ url: "https://trigger.dev" });
return "foo-bar";
},
});
const taskTwo = task({
id: "task-2",
async run(params) {
return {
hello: "world",
payload: params.payload.other,
};
},
});
const userTaskOne = task({
id: "user/task-1",
run: async (params) => {
return "foo-bar";
},
});
const userTaskTwo = task({
id: "user/task-2",
run: async (params) => {
return "foo-bar";
},
});
const zodTaskOne = task({
id: "zod/task-1",
schema: z.object({ foo: z.string() }),
run: async (params) => {},
});
const zodTaskTwo = task({
id: "zod/task-2",
schema: z.object({ foo: z.string(), isAdmin: z.boolean().default(false) }),
run: async (params) => {
console.log(params.payload.foo, params.meta.run);
},
});
const valibotTaskOne = task({
id: "valibot/task-1",
schema: wrap(
v.object({
foo: v.string(),
})
),
run: async (params) => {
await zodTaskOne.trigger({ foo: "bar" });
await zodTaskTwo.trigger({ foo: "bar" });
await valibotTaskTwo.trigger({ foo: "bar" });
},
});
const valibotTaskTwo = task({
id: "valibot/task-2",
schema: wrap(
v.object({
foo: v.string(),
isAdmin: v.optional(v.boolean(), true),
})
),
run: async (params) => {
await valibotTaskOne.trigger({ foo: "bar" });
},
});
// in trigger/lib.ts
const myTaskLibrary = taskLibrary({
myTasks: { taskOne, taskTwo },
});
const userTaskLibrary = taskLibrary({
userTaskOne,
userTaskTwo,
});
const zodTaskLibrary = taskLibrary({
zodTaskOne,
zodTaskTwo,
});
const valibotTaskLibrary = taskLibrary({
valibotTaskOne,
valibotTaskTwo,
});
export const library = taskLibrary({
foo: myTaskLibrary,
bar: userTaskLibrary,
zod: zodTaskLibrary,
valibot: valibotTaskLibrary,
});
// Export the library type
export type Library = typeof library;
// Now on the client
const client = createTriggerClient<Library>({
secretKey: "tr_dev_1234",
});
client.runs.retrieve("run_12343"); // Call regular API client calls
// Tasks are now available under lib
client.lib.foo.myTasks.taskOne.trigger("task-1", { hello: "world" });
client.lib.bar.userTaskOne.trigger("user/task-1", { userId: "user_123" });
client.lib.bar.userTaskTwo.trigger("user/task-2", { userId: "user_123", isAdmin: true });
client.lib.bar.userTaskTwo.trigger("user/task-2", { userId: "user_123", isAdmin: false });
client.lib.zod.zodTaskOne.trigger("zod/task-1", { foo: "bar" });
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar" });
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar", isAdmin: false });
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar" });
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar", isAdmin: true });
+499
View File
@@ -0,0 +1,499 @@
import { z } from "zod";
import * as v from "valibot";
export type ParserZodEsque<TInput, TParsedInput> = {
_input: TInput;
_output: TParsedInput;
};
export type ParserMyZodEsque<TInput> = {
parse: (input: any) => TInput;
};
export type ParserSuperstructEsque<TInput> = {
create: (input: unknown) => TInput;
};
export type ParserCustomValidatorEsque<TInput> = (input: unknown) => Promise<TInput> | TInput;
export type ParserYupEsque<TInput> = {
validateSync: (input: unknown) => TInput;
};
export type ParserScaleEsque<TInput> = {
assert(value: unknown): asserts value is TInput;
};
export type ParserWithoutInput<TInput> =
| ParserCustomValidatorEsque<TInput>
| ParserMyZodEsque<TInput>
| ParserScaleEsque<TInput>
| ParserSuperstructEsque<TInput>
| ParserYupEsque<TInput>;
export type ParserWithInputOutput<TInput, TParsedInput> = ParserZodEsque<TInput, TParsedInput>;
export type Parser = ParserWithInputOutput<any, any> | ParserWithoutInput<any>;
export type inferParser<TParser extends Parser> = TParser extends ParserWithInputOutput<
infer $TIn,
infer $TOut
>
? {
in: $TIn;
out: $TOut;
}
: TParser extends ParserWithoutInput<infer $InOut>
? {
in: $InOut;
out: $InOut;
}
: never;
export type Simplify<TType> = TType extends any[] | Date ? TType : { [K in keyof TType]: TType[K] };
export type TriggerResult = {
id: string;
};
export type TaskRunResult<TOutput = any> =
| {
ok: true;
id: string;
output: TOutput;
}
| {
ok: false;
id: string;
error: unknown;
};
export type RunMetadata = {
run: string;
};
export type RunFnParams<TPayload, TContext extends object> = {
/** Metadata about the task, run, attempt, queue, environment, organization, project and batch. */
meta: RunMetadata;
/** Context added by task middleware */
ctx: TContext;
payload: TPayload;
};
export type TaskOptions<
TPayloadIn,
TPayloadOut,
TOutput,
TContext extends object,
TIdentifier extends string,
> = {
/** An id for your task. This must be unique inside your project and not change between versions. */
id: TIdentifier;
schema?: Parser;
/** This gets called when a task is triggered. It's where you put the code you want to execute.
*
* @param payload - The payload that is passed to your task when it's triggered. This must be JSON serializable.
* @param params - Metadata about the run.
*/
run: (
params: Simplify<RunFnParams<inferTaskPayloadOut<TPayloadIn, TPayloadOut>, TContext>>
) => Promise<TOutput>;
};
export interface Task<TPayloadIn, TPayloadOut, TOutput, TIdentifier extends string> {
/**
* The id of the task.
*/
id: TIdentifier;
/**
* Trigger a task with the given payload, and continue without waiting for the result. If you want to wait for the result, use `triggerAndWait`. Returns the id of the triggered task run.
* @param payload
* @param options
* @returns TriggerResult
* - `id` - The id of the triggered task run.
*/
trigger: (
payload: Simplify<inferTaskPayloadIn<TPayloadIn, TPayloadOut>>,
options?: TriggerTaskOptions
) => Promise<TriggerResult>;
/**
* Trigger a task with the given payload, and wait for the result. Returns the result of the task run
* @param payload
* @param options - Options for the task run
* @returns TaskRunResult
* @example
* ```
* const result = await task.triggerAndWait({ foo: "bar" });
*
* if (result.ok) {
* console.log(result.output);
* } else {
* console.error(result.error);
* }
* ```
*/
triggerAndWait: (
payload: Simplify<inferTaskPayloadIn<TPayloadIn, TPayloadOut>>,
options?: TriggerTaskOptions
) => Promise<TaskRunResult<TOutput>>;
}
export type AnyTask = Task<any, any, any, string>;
type IsUnknown<T> = unknown extends T ? (T extends unknown ? true : false) : false;
type NonUnknown<T> = IsUnknown<T> extends true ? never : T;
export type inferTaskPayloadIn<TPayloadIn, TPayloadOut> = NonUnknown<TPayloadIn> extends never
? TPayloadOut
: TPayloadIn;
export type inferTaskPayloadOut<TPayloadIn, TPayloadOut> = NonUnknown<TPayloadOut> extends never
? TPayloadIn
: TPayloadOut;
export type TaskPayloadIn<TTask extends AnyTask> = TTask extends Task<
infer TPayloadIn,
infer TPayloadOut,
any,
string
>
? inferTaskPayloadIn<TPayloadIn, TPayloadOut>
: never;
export type TaskPayloadOut<TTask extends AnyTask> = TTask extends Task<
infer TPayloadIn,
infer TPayloadOut,
any,
string
>
? inferTaskPayloadOut<TPayloadIn, TPayloadOut>
: never;
export type TaskOutput<TTask extends AnyTask> = TTask extends Task<any, any, infer TOutput, string>
? TOutput
: never;
export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<
any,
any,
any,
infer TIdentifier
>
? TIdentifier
: never;
export type TaskTypes<TTask extends AnyTask> = TTask extends Task<
infer TPayloadIn,
infer TPayloadOut,
infer TOutput,
infer TIdentifier
>
? {
id: TIdentifier;
payloadIn: TPayloadIn;
payloadOut: TPayloadOut;
output: TOutput;
}
: never;
export type TriggerTaskOptions = {
idempotencyKey?: string;
maxAttempts?: number;
startAt?: Date;
startAfter?: number;
concurrencyKey?: string;
};
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export function task<
TPayloadIn,
TPayloadOut,
TOutput,
TContext extends object,
TIdentifier extends string,
>(
options: TaskOptions<TPayloadIn, TPayloadOut, TOutput, TContext, TIdentifier>
): Task<TPayloadIn, TPayloadOut, TOutput, TIdentifier> {
return createTask<TPayloadIn, TPayloadOut, TOutput, TContext, TIdentifier>(options);
}
export function createTask<
TPayloadIn,
TPayloadOut,
TOutput,
TContext extends object,
TIndentifier extends string,
>(
params: TaskOptions<TPayloadIn, TPayloadOut, TOutput, TContext, TIndentifier>
): Task<TPayloadIn, TPayloadOut, TOutput, TIndentifier> {
const task: Task<TPayloadIn, TPayloadOut, TOutput, TIndentifier> = {
id: params.id,
trigger: async (payload, options) => {
return {
id: "run_1234",
};
},
triggerAndWait: async (payload, options) => {
const output = await params.run({
meta: { run: "run_1234" },
payload: payload as unknown as inferTaskPayloadOut<TPayloadIn, TPayloadOut>,
ctx: {} as TContext,
});
return {
ok: true,
id: "run_1234",
output,
};
},
};
return task;
}
export type ZodTaskOptions<
TOutput,
TContext extends object,
TIdentifier extends string,
TSchema extends z.ZodTypeAny = z.ZodTypeAny,
> = {
schema: TSchema;
} & TaskOptions<z.input<TSchema>, z.output<TSchema>, TOutput, TContext, TIdentifier>;
export function zodTask<
TOutput,
TContext extends object,
TIdentifier extends string,
TSchema extends z.ZodTypeAny = z.ZodTypeAny,
>(
options: ZodTaskOptions<TOutput, TContext, TIdentifier, TSchema>
): Task<z.input<TSchema>, z.output<TSchema>, TOutput, TIdentifier> {
return createTask<z.input<TSchema>, z.output<TSchema>, TOutput, TContext, TIdentifier>(options);
}
export type ValibotTaskOptions<
TOutput,
TContext extends object,
TIdentifier extends string,
TSchema extends v.BaseSchema = v.AnySchema,
> = {
schema: TSchema;
} & TaskOptions<v.Input<TSchema>, v.Output<TSchema>, TOutput, TContext, TIdentifier>;
export function valibotTask<
TOutput,
TContext extends object,
TIdentifier extends string,
TSchema extends v.BaseSchema = v.AnySchema,
>(
options: ValibotTaskOptions<TOutput, TContext, TIdentifier, TSchema>
): Task<v.Input<TSchema>, v.Output<TSchema>, TOutput, TIdentifier> {
return createTask<v.Input<TSchema>, v.Output<TSchema>, TOutput, TContext, TIdentifier>(options);
}
export interface TaskLibraryRecord {
[key: string]: AnyTask | TaskLibraryRecord;
}
export interface TaskLibrary<TRecord extends TaskLibraryRecord> {
_def: { record: TRecord };
}
export type AnyTaskLibrary = TaskLibrary<any>;
export type CreateTaskLibraryOptions = {
[key: string]: AnyTask | AnyTaskLibrary | CreateTaskLibraryOptions;
};
export type DecorateCreateTaskLibraryOptions<TTaskLibraryOptions extends CreateTaskLibraryOptions> =
{
[K in keyof TTaskLibraryOptions]: TTaskLibraryOptions[K] extends infer $Value
? $Value extends AnyTask
? $Value
: $Value extends TaskLibrary<infer TRecord>
? TRecord
: $Value extends CreateTaskLibraryOptions
? DecorateCreateTaskLibraryOptions<$Value>
: never
: never;
};
function taskLibrary<TInput extends CreateTaskLibraryOptions>(
input: TInput
): TaskLibrary<DecorateCreateTaskLibraryOptions<TInput>>;
function taskLibrary<TInput extends TaskLibraryRecord>(input: TInput): TaskLibrary<TInput>;
function taskLibrary(input: TaskLibraryRecord | CreateTaskLibraryOptions) {
// TODO: reserved words
return {
_def: {
record: input,
},
};
}
// ======== client side
type DecorateTask<TTask extends AnyTask> = {
trigger: (id: TaskIdentifier<TTask>, payload: TaskPayloadIn<TTask>) => Promise<{ id: string }>;
};
type DecoratedTaskLibraryRecord<
TTaskLibrary extends AnyTaskLibrary,
TRecord extends TaskLibraryRecord,
> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TaskLibraryRecord
? DecoratedTaskLibraryRecord<TTaskLibrary, $Value>
: $Value extends AnyTask
? DecorateTask<$Value>
: never
: never;
};
export type inferTaskLibraryClient<TTaskLibrary extends AnyTaskLibrary> =
DecoratedTaskLibraryRecord<TTaskLibrary, TTaskLibrary["_def"]["record"]>;
export type CreateTriggerClient<TTaskLibrary extends AnyTaskLibrary> = {
lib: inferTaskLibraryClient<TTaskLibrary>;
runs: {
retrieve: (id: string) => Promise<{ status: boolean }>;
};
};
export type CreateTriggerClientOptions = {
secretKey?: string;
};
export function createTriggerClient<TTaskLibrary extends AnyTaskLibrary>(
options?: CreateTriggerClientOptions
): CreateTriggerClient<TTaskLibrary> {
return {} as CreateTriggerClient<TTaskLibrary>;
}
// trigger/my-tasks.ts
const taskOne = task({
id: "task-1",
run: async () => {
const handle = await taskTwo.trigger({ url: "https://trigger.dev" });
const result = await taskTwo.triggerAndWait({ url: "https://trigger.dev" });
return "foo-bar";
},
});
const taskTwo = task({
id: "task-2",
async run(params) {
return {
hello: "world",
payload: params.payload,
};
},
});
const userTaskOne = task({
id: "user/task-1",
run: async (params: { payload: { userId: string } }) => {
return "foo-bar";
},
});
const userTaskTwo = task({
id: "user/task-2",
run: async (params: { payload: { userId: string; isAdmin: boolean } }) => {
return "foo-bar";
},
});
const zodTaskOne = task({
id: "zod/task-1",
schema: z.object({ foo: z.string() }),
run: async (params) => {},
});
const zodTaskTwo = task({
id: "zod/task-2",
schema: z.object({ foo: z.string(), isAdmin: z.boolean().default(false) }),
run: async (params) => {
console.log(params.payload.foo, params.meta.run);
},
});
const valibotTaskOne = task({
id: "valibot/task-1",
schema: v.object({
foo: v.string(),
}),
run: async (params) => {
await zodTaskOne.trigger({ foo: "bar" });
await zodTaskTwo.trigger({ foo: "bar" });
await valibotTaskTwo.trigger({ foo: "bar" });
},
});
const valibotTaskTwo = task({
id: "valibot/task-2",
schema: v.object({
foo: v.string(),
isAdmin: v.optional(v.boolean(), true),
}),
run: async (params) => {
await valibotTaskOne.trigger({ foo: "bar" });
},
});
// in trigger/lib.ts
const myTaskLibrary = taskLibrary({
myTasks: { taskOne, taskTwo },
});
const userTaskLibrary = taskLibrary({
userTaskOne,
userTaskTwo,
});
const zodTaskLibrary = taskLibrary({
zodTaskOne,
zodTaskTwo,
});
const valibotTaskLibrary = taskLibrary({
valibotTaskOne,
valibotTaskTwo,
});
export const library = taskLibrary({
foo: myTaskLibrary,
bar: userTaskLibrary,
zod: zodTaskLibrary,
valibot: valibotTaskLibrary,
});
// Export the library type
export type Library = typeof library;
// Now on the client
const client = createTriggerClient<Library>({
secretKey: "tr_dev_1234",
});
client.runs.retrieve("run_12343"); // Call regular API client calls
// Tasks are now available under lib
client.lib.foo.myTasks.taskOne.trigger("task-1", { hello: "world" });
client.lib.bar.userTaskOne.trigger("user/task-1", { userId: "user_123" });
client.lib.bar.userTaskTwo.trigger("user/task-2", { userId: "user_123", isAdmin: true });
client.lib.bar.userTaskTwo.trigger("user/task-2", { userId: "user_123", isAdmin: false });
client.lib.zod.zodTaskOne.trigger("zod/task-1", { foo: "bar" });
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar" });
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar", isAdmin: false });
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar" });
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar", isAdmin: false });
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/airtable
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/airtable",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "Trigger.dev integration for airtable",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +25,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"airtable": "^0.12.1",
"zod": "3.22.3"
},
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/github
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/github",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "The official GitHub integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -30,8 +30,8 @@
"@octokit/request-error": "^5.0.1",
"@octokit/webhooks": "^12.0.10",
"octokit": "^3.1.2",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"zod": "3.22.3"
},
"engines": {
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/linear
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/linear",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "Trigger.dev integration for @linear/sdk",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@linear/sdk": "^8.0.0",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"zod": "3.22.3"
},
"engines": {
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/slack
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/openai",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "The official OpenAI integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -42,8 +42,8 @@
},
"dependencies": {
"openai": "^4.16.1",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29"
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31"
},
"engines": {
"node": ">=18.0.0"
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/plain
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/plain",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "The official Plain.com integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -24,8 +24,8 @@
"build:tsup": "tsup"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"@team-plain/typescript-sdk": "^2.7.0"
},
"engines": {
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/replicate
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/replicate",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "Trigger.dev integration for replicate",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +25,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"replicate": "^0.18.1",
"zod": "3.22.3"
},
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/resend
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/resend",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "The official Resend.com integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -24,8 +24,8 @@
"build:tsup": "tsup"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"resend": "^2.1.0"
},
"engines": {
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/sendgrid
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/sendgrid",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "Trigger.dev integration for @sendgrid/mail",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@sendgrid/mail": "^7.7.0",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29"
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31"
},
"engines": {
"node": ">=16.8.0"
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/shopify
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/shopify",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "Trigger.dev integration for @shopify/shopify-api",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@shopify/shopify-api": "^8.0.2",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
"zod": "3.22.3"
},
"engines": {
+12
View File
@@ -1,5 +1,17 @@
# @trigger.dev/slack
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/slack",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "The official Slack integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,7 +25,7 @@
},
"dependencies": {
"@slack/web-api": "^6.8.1",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"zod": "3.22.3"
},
"engines": {
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/stripe
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/stripe",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "Trigger.dev integration for stripe",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +25,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"stripe": "^12.14.0",
"zod": "3.22.3"
},
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/supabase
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/supabase",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "Trigger.dev integration for @supabase/supabase-js",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@supabase/supabase-js": "^2.26.0",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"supabase-management-js": "^1.0.0",
"zod": "3.22.3"
},
+14
View File
@@ -1,5 +1,19 @@
# @trigger.dev/typeform
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.31
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.30
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/typeform",
"version": "3.0.0-beta.29",
"version": "3.0.0-beta.31",
"description": "The official Typeform integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -24,8 +24,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
"@typeform/api-client": "^1.8.0",
"zod": "3.22.3"
},
+12
View File
@@ -1,5 +1,17 @@
# @trigger.dev/astro
## 3.0.0-beta.31
### Patch Changes
- @trigger.dev/sdk@3.0.0-beta.31
## 3.0.0-beta.30
### Patch Changes
- @trigger.dev/sdk@3.0.0-beta.30
## 3.0.0-beta.29
### Patch Changes

Some files were not shown because too many files have changed in this diff Show More