chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
import { type RuntimeEnvironmentType, type ScheduleType } from "@trigger.dev/database";
|
||||
import { type ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { getTaskIdentifiers } from "~/models/task.server";
|
||||
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
|
||||
import {
|
||||
calculateNextScheduledTimestampFromNow,
|
||||
previousScheduledTimestamp,
|
||||
} from "~/v3/utils/calculateNextSchedule.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type ScheduleListOptions = {
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
userId?: string;
|
||||
pageSize?: number;
|
||||
} & ScheduleListFilters;
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export type ScheduleListItem = {
|
||||
id: string;
|
||||
type: ScheduleType;
|
||||
friendlyId: string;
|
||||
taskIdentifier: string;
|
||||
deduplicationKey: string | null;
|
||||
userProvidedDeduplicationKey: boolean;
|
||||
cron: string;
|
||||
cronDescription: string;
|
||||
timezone: string;
|
||||
externalId: string | null;
|
||||
nextRun: Date;
|
||||
lastRun: Date | undefined;
|
||||
active: boolean;
|
||||
environments: {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
userName?: string;
|
||||
branchName?: string;
|
||||
}[];
|
||||
};
|
||||
export type ScheduleList = Awaited<ReturnType<ScheduleListPresenter["call"]>>;
|
||||
export type ScheduleListAppliedFilters = ScheduleList["filters"];
|
||||
|
||||
export class ScheduleListPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectId,
|
||||
environmentId,
|
||||
tasks,
|
||||
search,
|
||||
page,
|
||||
type,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: ScheduleListOptions) {
|
||||
const hasFilters =
|
||||
type !== undefined || tasks !== undefined || (search !== undefined && search !== "");
|
||||
|
||||
const filterType =
|
||||
type === "declarative" ? "DECLARATIVE" : type === "imperative" ? "IMPERATIVE" : undefined;
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
branchName: true,
|
||||
archivedAt: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const environment = project.environments.find((env) => env.id === environmentId);
|
||||
if (!environment) {
|
||||
throw new ServiceValidationError("No matching environment for project", 404);
|
||||
}
|
||||
|
||||
const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({
|
||||
prisma: this._replica,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// Two platform RPCs in parallel. We derive `limit` from `currentPlan`
|
||||
// rather than calling `getLimit` separately (which would re-issue
|
||||
// `client.currentPlan` upstream — same data fetched twice). The
|
||||
// scheduled-task route awaits this presenter synchronously, so every ms
|
||||
// here is TTFB.
|
||||
const [currentPlan, plans] = await Promise.all([
|
||||
getCurrentPlan(project.organizationId),
|
||||
getPlans(),
|
||||
]);
|
||||
|
||||
const planLimit = currentPlan?.v3Subscription?.plan?.limits.schedules?.number;
|
||||
const limit = typeof planLimit === "number" ? planLimit : 100_000_000;
|
||||
const extraSchedules = currentPlan?.v3Subscription?.addOns?.schedules?.purchased ?? 0;
|
||||
const canPurchaseSchedules =
|
||||
currentPlan?.v3Subscription?.plan?.limits.schedules.canExceed === true;
|
||||
const maxScheduleQuota = currentPlan?.v3Subscription?.addOns?.schedules?.quota ?? 0;
|
||||
const planScheduleLimit = limit - extraSchedules;
|
||||
const schedulePricing = plans?.addOnPricing.schedules ?? null;
|
||||
|
||||
const purchaseInfo = {
|
||||
canPurchaseSchedules,
|
||||
extraSchedules,
|
||||
maxScheduleQuota,
|
||||
planScheduleLimit,
|
||||
schedulePricing,
|
||||
};
|
||||
|
||||
//get the latest BackgroundWorker
|
||||
const latestWorker = await findCurrentWorkerFromEnvironment(environment, this._replica);
|
||||
if (!latestWorker) {
|
||||
return {
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
totalCount: 0,
|
||||
schedules: [],
|
||||
possibleTasks: [],
|
||||
hasFilters,
|
||||
limits: {
|
||||
used: schedulesCount,
|
||||
limit,
|
||||
},
|
||||
...purchaseInfo,
|
||||
filters: {
|
||||
tasks,
|
||||
search,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
//get all possible scheduled tasks
|
||||
const allIdentifiers = await getTaskIdentifiers(environmentId);
|
||||
const possibleTasks = allIdentifiers
|
||||
.filter((t) => t.triggerSource === "SCHEDULED" && t.isInLatestDeployment)
|
||||
.map((t) => ({ slug: t.slug }));
|
||||
|
||||
//do this here to protect against SQL injection
|
||||
search = search && search !== "" ? `%${search}%` : undefined;
|
||||
|
||||
const totalCount = await this._replica.taskSchedule.count({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
taskIdentifier: tasks ? { in: tasks } : undefined,
|
||||
instances: {
|
||||
some: {
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
type: filterType,
|
||||
AND: search
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
externalId: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
friendlyId: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
deduplicationKey: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
generatorExpression: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const rawSchedules = await this._replica.taskSchedule.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
deduplicationKey: true,
|
||||
userProvidedDeduplicationKey: true,
|
||||
generatorExpression: true,
|
||||
generatorDescription: true,
|
||||
timezone: true,
|
||||
externalId: true,
|
||||
instances: {
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
active: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
where: {
|
||||
projectId: project.id,
|
||||
taskIdentifier: tasks ? { in: tasks } : undefined,
|
||||
instances: {
|
||||
some: {
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
type: filterType,
|
||||
AND: search
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
externalId: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
friendlyId: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
deduplicationKey: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
generatorExpression: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
});
|
||||
|
||||
const schedules: ScheduleListItem[] = rawSchedules.map((schedule) => {
|
||||
// Approximate "last run" from the cron's previous slot. Skip inactive
|
||||
// schedules — the cron's previous slot reflects what *would* have
|
||||
// fired, but a deactivated schedule didn't actually fire there. Skip
|
||||
// when the cron's previous slot predates `updatedAt`: any config
|
||||
// change (cron edited, timezone changed, deactivate/reactivate)
|
||||
// bumps updatedAt, and a slot from before the most recent change
|
||||
// didn't fire under the current configuration. cron-parser throws
|
||||
// on malformed expressions, so degrade to undefined per-row rather
|
||||
// than failing the whole list. UI is best-effort; the runs page is
|
||||
// the source of truth.
|
||||
let lastRun: Date | undefined;
|
||||
if (schedule.active) {
|
||||
try {
|
||||
const cronPrev = previousScheduledTimestamp(
|
||||
schedule.generatorExpression,
|
||||
schedule.timezone
|
||||
);
|
||||
lastRun = cronPrev.getTime() > schedule.updatedAt.getTime() ? cronPrev : undefined;
|
||||
} catch {
|
||||
lastRun = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
type: schedule.type,
|
||||
friendlyId: schedule.friendlyId,
|
||||
taskIdentifier: schedule.taskIdentifier,
|
||||
deduplicationKey: schedule.deduplicationKey,
|
||||
userProvidedDeduplicationKey: schedule.userProvidedDeduplicationKey,
|
||||
cron: schedule.generatorExpression,
|
||||
cronDescription: schedule.generatorDescription,
|
||||
timezone: schedule.timezone,
|
||||
active: schedule.active,
|
||||
externalId: schedule.externalId,
|
||||
lastRun,
|
||||
nextRun: calculateNextScheduledTimestampFromNow(
|
||||
schedule.generatorExpression,
|
||||
schedule.timezone
|
||||
),
|
||||
environments: schedule.instances.map((instance) => {
|
||||
const environment = project.environments.find((env) => env.id === instance.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error(
|
||||
`Environment not found for TaskScheduleInstance env: ${instance.environmentId}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...displayableEnvironment(environment, userId),
|
||||
branchName: environment.branchName ?? undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
totalCount: totalCount,
|
||||
schedules,
|
||||
possibleTasks: possibleTasks.map((task) => task.slug),
|
||||
hasFilters,
|
||||
limits: {
|
||||
used: schedulesCount,
|
||||
limit,
|
||||
},
|
||||
...purchaseInfo,
|
||||
filters: {
|
||||
tasks,
|
||||
search,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user