feat(schedule-engine): stop persisting per-tick schedule state (#3476)

## Summary

Each scheduled-task tick previously issued **3 Prisma `UPDATE`s**
against
`TaskSchedule.lastRunTriggeredAt`,
`TaskScheduleInstance.lastScheduledTimestamp`,
and `TaskScheduleInstance.nextScheduledTimestamp`. All three were pure
denormalization — every value can be derived without persisting.

After this PR `TaskSchedule` and `TaskScheduleInstance` become **near
read-only**:
writes happen only on schedule create / update / delete (rare admin
actions),
so the per-tick autovacuum churn on these hot tables disappears.

## Design

The previous fire time travels forward through the **schedule worker
payload**,
not through the database. Concretely:

- The `schedule.triggerScheduledTask` worker payload gains an optional
  `lastScheduleTime: z.coerce.date().optional()` field.
- When the engine fires a schedule, it re-enqueues the next tick with
  `lastScheduleTime = scheduleTimestamp` (the just-fired time).
- When the next tick dequeues, `payload.lastTimestamp` is sourced from
`params.lastScheduleTime` directly. No DB round-trip, no cron-derivation
  drift across DST boundaries, no caveats around recently-edited cron
  expressions.

`payload.lastTimestamp` keeps its `Date | undefined` SDK shape.
First-ever
fires still report `undefined`, so customer `if
(!payload.lastTimestamp)`
first-run patterns keep working.

For Redis jobs that were enqueued **before** this change (which lack
`lastScheduleTime` in their payload), the engine falls back to
`instance.lastScheduledTimestamp` once. Once those drain, the column is
never read again. Revert is code-only; the columns stay in place and can
be dropped in a follow-up once the rollout is stable.

## Files

- `internal-packages/schedule-engine/*` — engine refactor,
`workerCatalog`
schema field, `TriggerScheduleParams` extension, tests updated to assert
  on the worker-payload flow rather than DB readbacks.
- `internal-packages/database/prisma/schema.prisma` — `/// @deprecated`
  triple-slash docstrings on the three columns. No migration.
- `apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts` —
drops
the `lastRunTriggeredAt` Prisma select; "Last run" cell is approximated
from the cron expression's previous slot, gated on `schedule.createdAt`
so brand-new schedules show "–". UI is best-effort; the runs page is the
  source of truth.
- `apps/webapp/app/v3/utils/calculateNextSchedule.server.ts` — adds a
  `previousScheduledTimestamp` helper for the UI cell above. Public API
responses (`api.v1.schedules.*`) already compute `nextRun` from cron and
  don't expose `lastTimestamp` — no public API change.
- `references/scheduled-tasks/` — new reference project with declarative
  schedules at multiple cadences and three throw-on-fail validators
(`first-fire-detector`, `interval-validator`, `upcoming-validator`) for
  E2E-verifying the worker-payload flow.

Refs TRI-8891

## Test plan

- [x] `pnpm run typecheck --filter @internal/schedule-engine --filter
webapp`
- [x] `pnpm run build --filter @trigger.dev/core`
- [x] `pnpm run test --filter @internal/schedule-engine` — integration
test
asserts first-fire `lastTimestamp === undefined`, second fire carries
      the previous fire's timestamp exactly.
- [x] E2E against local webapp via `references/scheduled-tasks`:
- Fresh schedules attached → all three deprecated columns stay `NULL`
after
    multiple fires.
  - Redis payload at second fire contains
    `"lastScheduleTime":"<previous fire timestamp>"`.
- `TaskRun.payload` and the every-minute task's returned output both
confirm
`lastTimestamp = null` on first fire and `lastTimestamp = <prev fire>`
on
    second fire, exactly 60s apart.
  - All three throw-on-FAIL validators completed successfully on every
    non-first fire.
- [x] Schedules REST API end-to-end (`POST` / `GET` / `PUT` / `activate`
/
`deactivate` / `DELETE`) — `nextRun` recomputed live from cron + tz on
      every response, no reads of deprecated columns.
This commit is contained in:
Eric Allam
2026-05-01 08:22:39 +01:00
committed by GitHub
parent 19c16759f6
commit ac7177d61f
16 changed files with 861 additions and 150 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Stop writing per-tick state (`lastScheduledTimestamp`, `nextScheduledTimestamp`, `lastRunTriggeredAt`) on `TaskSchedule` and `TaskScheduleInstance`. The schedule engine now carries the previous fire time forward via the worker queue payload, eliminating ~270K dead-tuple-driven autovacuums per year on these hot tables and the associated `IO:XactSync` mini-spikes on the writer. Customer-facing `payload.lastTimestamp` semantics are unchanged.
@@ -6,7 +6,10 @@ import { getLimit } 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 } from "~/v3/utils/calculateNextSchedule.server";
import {
calculateNextScheduledTimestampFromNow,
previousScheduledTimestamp,
} from "~/v3/utils/calculateNextSchedule.server";
import { BasePresenter } from "./basePresenter.server";
type ScheduleListOptions = {
@@ -193,8 +196,8 @@ export class ScheduleListPresenter extends BasePresenter {
},
},
active: true,
lastRunTriggeredAt: true,
createdAt: true,
updatedAt: true,
},
where: {
projectId: project.id,
@@ -244,6 +247,29 @@ export class ScheduleListPresenter extends BasePresenter {
});
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,
@@ -256,7 +282,7 @@ export class ScheduleListPresenter extends BasePresenter {
timezone: schedule.timezone,
active: schedule.active,
externalId: schedule.externalId,
lastRun: schedule.lastRunTriggeredAt ?? undefined,
lastRun,
nextRun: calculateNextScheduledTimestampFromNow(
schedule.generatorExpression,
schedule.timezone
@@ -22,6 +22,20 @@ function calculateNextStep(schedule: string, timezone: string | null, currentDat
.toDate();
}
export function previousScheduledTimestamp(
schedule: string,
timezone: string | null,
fromTimestamp: Date = new Date()
) {
return parseExpression(schedule, {
currentDate: fromTimestamp,
utc: timezone === null,
tz: timezone ?? undefined,
})
.prev()
.toDate();
}
export function nextScheduledTimestamps(
cron: string,
timezone: string | null,
@@ -2128,6 +2128,7 @@ model TaskSchedule {
///Instances of the schedule that are active
instances TaskScheduleInstance[]
/// @deprecated stop writing 2026-04-30; reads moved out of code (UI now derives from cron's previous slot). Drop in follow-up.
lastRunTriggeredAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@ -2173,7 +2174,9 @@ model TaskScheduleInstance {
active Boolean @default(true)
/// @deprecated stop writing 2026-04-30; engine derives from cron + exactScheduleTime. Drop in follow-up.
lastScheduledTimestamp DateTime?
/// @deprecated stop writing 2026-04-30; engine derives from cron + now(). Drop in follow-up.
nextScheduledTimestamp DateTime?
//you can only have a schedule attached to each environment once
@@ -11,7 +11,11 @@ import { Logger } from "@trigger.dev/core/logger";
import { PrismaClient } from "@trigger.dev/database";
import { Worker, type JobHandlerParams } from "@trigger.dev/redis-worker";
import { calculateDistributedExecutionTime } from "./distributedScheduling.js";
import { calculateNextScheduledTimestamp, nextScheduledTimestamps } from "./scheduleCalculation.js";
import {
calculateNextScheduledTimestamp,
nextScheduledTimestamps,
previousScheduledTimestamp,
} from "./scheduleCalculation.js";
import {
RegisterScheduleInstanceParams,
ScheduleEngineOptions,
@@ -171,13 +175,13 @@ export class ScheduleEngine {
instance.taskSchedule.generatorExpression
);
const lastScheduledTimestamp = instance.lastScheduledTimestamp ?? new Date();
span.setAttribute("last_scheduled_timestamp", lastScheduledTimestamp.toISOString());
const fromTimestamp = params.fromTimestamp ?? new Date();
span.setAttribute("from_timestamp", fromTimestamp.toISOString());
const nextScheduledTimestamp = calculateNextScheduledTimestamp(
instance.taskSchedule.generatorExpression,
instance.taskSchedule.timezone,
lastScheduledTimestamp
fromTimestamp
);
span.setAttribute("next_scheduled_timestamp", nextScheduledTimestamp.toISOString());
@@ -185,7 +189,7 @@ export class ScheduleEngine {
const schedulingDelayMs = nextScheduledTimestamp.getTime() - Date.now();
span.setAttribute("scheduling_delay_ms", schedulingDelayMs);
this.logger.info("Calculated next schedule timestamp", {
this.logger.debug("Calculated next schedule timestamp", {
instanceId: params.instanceId,
taskIdentifier: instance.taskSchedule.taskIdentifier,
nextScheduledTimestamp: nextScheduledTimestamp.toISOString(),
@@ -194,17 +198,44 @@ export class ScheduleEngine {
timezone: instance.taskSchedule.timezone,
});
await this.prisma.taskScheduleInstance.update({
where: {
id: params.instanceId,
},
data: {
nextScheduledTimestamp,
},
});
// Determine the lastScheduleTime to embed in the next worker job's
// payload. If the caller passed it explicitly (the after-fire path
// does this with the just-fired timestamp, the after-skip path
// carries the existing value forward), use that. Otherwise — every
// external caller (deploy sync, schedule upsert, recovery) — derive
// from the cron expression's previous slot.
//
// Without this fallback, every deploy / cron edit would clobber the
// existing in-flight job's lastScheduleTime with `undefined`, and
// the next fire would surface a frozen DB-column value to the
// customer (since this PR stops writing that column). Pure cron
// math, no DB read on top of the existing instance load — the
// recovery loop already pays the cost of loading the instance.
let lastScheduleTime = params.lastScheduleTime;
if (lastScheduleTime === undefined) {
try {
const cronPrev = previousScheduledTimestamp(
instance.taskSchedule.generatorExpression,
instance.taskSchedule.timezone
);
// Guarded against the cron's previous slot predating the
// instance itself — for a brand-new schedule, the slot is from
// before the schedule existed, so `undefined` is the honest
// answer (preserves the `if (!payload.lastTimestamp)` first-run
// sentinel customers rely on).
if (cronPrev.getTime() > instance.createdAt.getTime()) {
lastScheduleTime = cronPrev;
}
} catch {
// Malformed cron — leave undefined.
}
}
// Enqueue the scheduled task
await this.enqueueScheduledTask(params.instanceId, nextScheduledTimestamp);
await this.enqueueScheduledTask(
params.instanceId,
nextScheduledTimestamp,
lastScheduleTime
);
// Record metrics
this.scheduleRegistrationCounter.add(1, {
@@ -244,6 +275,7 @@ export class ScheduleEngine {
instanceId: payload.instanceId,
finalAttempt: false, // TODO: implement retry logic
exactScheduleTime: payload.exactScheduleTime,
lastScheduleTime: payload.lastScheduleTime,
});
}
@@ -350,14 +382,6 @@ export class ScheduleEngine {
skipReason = "schedule_inactive";
}
if (!instance.nextScheduledTimestamp) {
this.logger.debug("No next scheduled timestamp", {
instanceId: params.instanceId,
});
shouldTrigger = false;
skipReason = "no_next_timestamp";
}
// For development environments, check if there's an active session
if (instance.environment.type === "DEVELOPMENT") {
this.devEnvironmentCheckCounter.add(1, {
@@ -396,15 +420,26 @@ export class ScheduleEngine {
}
// Calculate the schedule timestamp that will be used (regardless of whether we trigger or not)
const scheduleTimestamp =
params.exactScheduleTime ?? instance.nextScheduledTimestamp ?? new Date();
const scheduleTimestamp = params.exactScheduleTime ?? new Date();
if (shouldTrigger) {
// payload.lastTimestamp is the actual previous fire time. Sources, in
// order:
// 1. params.lastScheduleTime — populated by the engine when this
// job was enqueued. Always present for jobs enqueued post-deploy.
// 2. instance.lastScheduledTimestamp — backward-compat fallback for
// in-flight Redis jobs enqueued by older engines that didn't
// include lastScheduleTime in the payload. Once those drain
// this fallback never triggers and we can drop the column.
// 3. undefined — first-ever fire (no previous fire to point at).
const lastTimestamp =
params.lastScheduleTime ?? instance.lastScheduledTimestamp ?? undefined;
const payload = {
scheduleId: instance.taskSchedule.friendlyId,
type: instance.taskSchedule.type as "DECLARATIVE" | "IMPERATIVE",
timestamp: scheduleTimestamp,
lastTimestamp: instance.lastScheduledTimestamp ?? undefined,
lastTimestamp,
externalId: instance.taskSchedule.externalId ?? undefined,
timezone: instance.taskSchedule.timezone,
upcoming: nextScheduledTimestamps(
@@ -422,13 +457,13 @@ export class ScheduleEngine {
span.setAttribute("scheduling_accuracy_ms", schedulingAccuracyMs);
span.setAttribute("actual_execution_time", actualExecutionTime.toISOString());
this.logger.info("Triggering scheduled task", {
this.logger.debug("Triggering scheduled task", {
instanceId: params.instanceId,
taskIdentifier: instance.taskSchedule.taskIdentifier,
scheduleTimestamp: scheduleTimestamp.toISOString(),
actualExecutionTime: actualExecutionTime.toISOString(),
schedulingAccuracyMs,
lastTimestamp: instance.lastScheduledTimestamp?.toISOString(),
lastTimestamp: lastTimestamp?.toISOString(),
});
const triggerStartTime = Date.now();
@@ -473,17 +508,7 @@ export class ScheduleEngine {
);
} else if (result) {
if (result.success) {
// Update the last run triggered timestamp
await this.prisma.taskSchedule.update({
where: {
id: instance.taskSchedule.id,
},
data: {
lastRunTriggeredAt: new Date(),
},
});
this.logger.info("Successfully triggered scheduled task", {
this.logger.debug("Successfully triggered scheduled task", {
instanceId: params.instanceId,
taskIdentifier: instance.taskSchedule.taskIdentifier,
durationMs: triggerDuration,
@@ -542,20 +567,23 @@ export class ScheduleEngine {
});
}
// Always update the last scheduled timestamp and register next run
await this.prisma.taskScheduleInstance.update({
where: {
id: params.instanceId,
},
data: {
lastScheduledTimestamp: scheduleTimestamp,
},
});
// Register the next run. `fromTimestamp` advances on every tick so
// the next cron slot keeps marching forward even through skips.
// `lastScheduleTime` is the actual previous fire time the next job
// will report as `payload.lastTimestamp` — only advance it when we
// actually triggered, otherwise carry forward the existing value so
// a long pause/disconnect doesn't quietly overwrite the real
// last-fire timestamp with a series of skipped slots.
const carriedLastScheduleTime = shouldTrigger
? scheduleTimestamp
: params.lastScheduleTime ?? instance.lastScheduledTimestamp ?? undefined;
// Register the next run
// Rewritten try/catch to use tryCatch utility
const [nextRunError] = await tryCatch(
this.registerNextTaskScheduleInstance({ instanceId: params.instanceId })
this.registerNextTaskScheduleInstance({
instanceId: params.instanceId,
fromTimestamp: scheduleTimestamp,
lastScheduleTime: carriedLastScheduleTime,
})
);
if (nextRunError) {
this.logger.error("Failed to schedule next run after execution", {
@@ -610,10 +638,17 @@ export class ScheduleEngine {
/**
* Enqueues a scheduled task with distributed execution timing
*/
private async enqueueScheduledTask(instanceId: string, exactScheduleTime: Date) {
private async enqueueScheduledTask(
instanceId: string,
exactScheduleTime: Date,
lastScheduleTime?: Date
) {
return startSpan(this.tracer, "enqueueScheduledTask", async (span) => {
span.setAttribute("instanceId", instanceId);
span.setAttribute("exactScheduleTime", exactScheduleTime.toISOString());
if (lastScheduleTime) {
span.setAttribute("lastScheduleTime", lastScheduleTime.toISOString());
}
const distributedExecutionTime = calculateDistributedExecutionTime(
exactScheduleTime,
@@ -646,6 +681,7 @@ export class ScheduleEngine {
payload: {
instanceId,
exactScheduleTime,
lastScheduleTime,
},
availableAt: distributedExecutionTime,
});
@@ -694,12 +730,12 @@ export class ScheduleEngine {
select: {
id: true,
generatorExpression: true,
timezone: true,
instances: {
select: {
id: true,
environmentId: true,
lastScheduledTimestamp: true,
nextScheduledTimestamp: true,
createdAt: true,
},
},
},
@@ -733,7 +769,7 @@ export class ScheduleEngine {
} as { recovered: string[]; skipped: string[] };
for (const { instance, schedule } of instancesWithSchedule) {
this.logger.info("Recovering schedule", {
this.logger.debug("Recovering schedule", {
schedule,
instance,
});
@@ -774,16 +810,15 @@ export class ScheduleEngine {
instance: {
id: string;
environmentId: string;
lastScheduledTimestamp: Date | null;
nextScheduledTimestamp: Date | null;
createdAt: Date;
};
schedule: { id: string; generatorExpression: string };
schedule: { id: string; generatorExpression: string; timezone: string | null };
}) {
// inspect the schedule worker to see if there is a job for this instance
const job = await this.worker.getJob(`scheduled-task-instance:${instance.id}`);
if (job) {
this.logger.info("Job already exists for instance", {
this.logger.debug("Job already exists for instance", {
instanceId: instance.id,
job,
schedule,
@@ -792,12 +827,15 @@ export class ScheduleEngine {
return "skipped";
}
this.logger.info("No job found for instance, registering next run", {
this.logger.debug("No job found for instance, registering next run", {
instanceId: instance.id,
schedule,
});
// If the job does not exist, register the next run
// No `lastScheduleTime` passed — `registerNextTaskScheduleInstance`
// will derive it from the cron's previous slot (with a createdAt
// guard) so the post-recovery fire reports an accurate
// `payload.lastTimestamp`.
await this.registerNextTaskScheduleInstance({ instanceId: instance.id });
return "recovered";
@@ -29,6 +29,26 @@ function calculateNextStep(schedule: string, timezone: string | null, currentDat
.toDate();
}
/**
* Cron's previous slot relative to `fromTimestamp`. For a continuously-
* running schedule this equals the actual last fire time; for paused or
* DST-edge cases it's an approximation. Used only on the recovery path
* where the actual last fire isn't recoverable from in-flight worker state.
*/
export function previousScheduledTimestamp(
cron: string,
timezone: string | null,
fromTimestamp: Date = new Date()
) {
return parseExpression(cron, {
currentDate: fromTimestamp,
utc: timezone === null,
tz: timezone ?? undefined,
})
.prev()
.toDate();
}
export function nextScheduledTimestamps(
cron: string,
timezone: string | null,
@@ -74,8 +74,22 @@ export interface TriggerScheduleParams {
instanceId: string;
finalAttempt: boolean;
exactScheduleTime?: Date;
lastScheduleTime?: Date;
}
export interface RegisterScheduleInstanceParams {
instanceId: string;
/**
* Anchor for computing the next cron slot. Defaults to now() when omitted.
* This advances on every tick (fired or skipped) so the next slot keeps
* marching forward regardless of skip reasons.
*/
fromTimestamp?: Date;
/**
* The actual previous fire time to embed in the next worker job's payload,
* which becomes that job's `payload.lastTimestamp` on dequeue. Distinct
* from `fromTimestamp` so that skipped ticks (inactive schedule, dev env
* disconnected, etc.) do NOT advance this — only real fires do.
*/
lastScheduleTime?: Date;
}
@@ -5,6 +5,11 @@ export const scheduleWorkerCatalog = {
schema: z.object({
instanceId: z.string(),
exactScheduleTime: z.coerce.date(),
// Optional for backward compat with in-flight jobs enqueued by older
// engines. After deploy, every newly-enqueued job populates this with
// the just-fired schedule time so the next dequeue can report
// payload.lastTimestamp accurately without a DB round-trip.
lastScheduleTime: z.coerce.date().optional(),
}),
visibilityTimeoutMs: 60_000,
retry: {
@@ -98,30 +98,13 @@ describe("ScheduleEngine Integration", () => {
},
});
// Calculate the expected next execution time (next minute boundary)
const now = new Date();
const expectedExecutionTime = new Date(now);
expectedExecutionTime.setMinutes(now.getMinutes() + 1, 0, 0); // Next minute, 0 seconds, 0 milliseconds
// Calculate the expected upcoming execution times (next 10 minutes after the first execution)
const expectedUpcoming = [];
for (let i = 1; i <= 10; i++) {
const upcoming = new Date(expectedExecutionTime);
upcoming.setMinutes(expectedExecutionTime.getMinutes() + i);
expectedUpcoming.push(upcoming);
}
// Manually enqueue the first scheduled task to kick off the lifecycle
// Manually enqueue the first scheduled task to kick off the lifecycle.
// Anchor expectations to the first observed `exactScheduleTime` rather
// than a precomputed wall-clock value — registration that happens to
// straddle a minute boundary used to flake tests asserting against a
// pre-baked "next minute".
await engine.registerNextTaskScheduleInstance({ instanceId: scheduleInstance.id });
// Get the actual nextScheduledTimestamp that was calculated by the engine
const instanceAfterRegistration = await prisma.taskScheduleInstance.findFirst({
where: { id: scheduleInstance.id },
});
const actualNextExecution = instanceAfterRegistration?.nextScheduledTimestamp;
expect(actualNextExecution).toBeDefined();
expect(actualNextExecution).toEqual(expectedExecutionTime);
// Wait for the first execution
console.log("Waiting for first execution...");
const startTime = Date.now();
@@ -181,6 +164,17 @@ describe("ScheduleEngine Integration", () => {
}
}
// Anchor all expectations to what the engine actually fired with, so
// the test stays deterministic regardless of when within a minute it
// started.
const firstScheduledTime = firstExecution.params.exactScheduleTime;
const secondScheduledTime = secondExecution.params.exactScheduleTime;
expect(firstScheduledTime).toBeDefined();
expect(secondScheduledTime).toBeDefined();
// Each cron slot for "* * * * *" is exactly 60s apart.
expect(secondScheduledTime!.getTime() - firstScheduledTime!.getTime()).toBe(60_000);
// Verify the first execution parameters
expect(firstExecution.params).toEqual({
taskIdentifier: "test-task",
@@ -201,68 +195,152 @@ describe("ScheduleEngine Integration", () => {
payload: {
scheduleId: "sched_abc123",
type: "DECLARATIVE",
timestamp: actualNextExecution,
lastTimestamp: undefined, // First run has no lastTimestamp
timestamp: firstScheduledTime,
// First-ever fire: no `lastScheduleTime` carried in the worker
// payload and `instance.lastScheduledTimestamp` is null on a
// fresh instance, so lastTimestamp is undefined. This preserves
// the `if (!payload.lastTimestamp)` first-run sentinel customers
// rely on.
lastTimestamp: undefined,
externalId: "ext-123",
timezone: "UTC",
upcoming: expect.arrayContaining([expect.any(Date)]),
},
scheduleInstanceId: scheduleInstance.id,
scheduleId: taskSchedule.id,
exactScheduleTime: actualNextExecution,
exactScheduleTime: firstScheduledTime,
});
// Verify the second execution parameters
if (actualNextExecution) {
const expectedSecondExecution = new Date(actualNextExecution);
expectedSecondExecution.setMinutes(actualNextExecution.getMinutes() + 1);
expect(secondExecution.params).toEqual({
taskIdentifier: "test-task",
environment: expect.objectContaining({
id: environment.id,
type: "PRODUCTION",
}),
payload: {
scheduleId: "sched_abc123",
type: "DECLARATIVE",
timestamp: expectedSecondExecution,
lastTimestamp: actualNextExecution, // Second run should have the first execution time as lastTimestamp
externalId: "ext-123",
timezone: "UTC",
upcoming: expect.arrayContaining([expect.any(Date)]),
},
scheduleInstanceId: scheduleInstance.id,
scheduleId: taskSchedule.id,
exactScheduleTime: expectedSecondExecution,
});
}
// Verify database updates occurred after both executions
const updatedSchedule = await prisma.taskSchedule.findFirst({
where: { id: taskSchedule.id },
expect(secondExecution.params).toEqual({
taskIdentifier: "test-task",
environment: expect.objectContaining({
id: environment.id,
type: "PRODUCTION",
}),
payload: {
scheduleId: "sched_abc123",
type: "DECLARATIVE",
timestamp: secondScheduledTime,
// The previous fire's exactScheduleTime is carried through the
// worker payload as `lastScheduleTime` and surfaced here.
lastTimestamp: firstScheduledTime,
externalId: "ext-123",
timezone: "UTC",
upcoming: expect.arrayContaining([expect.any(Date)]),
},
scheduleInstanceId: scheduleInstance.id,
scheduleId: taskSchedule.id,
exactScheduleTime: secondScheduledTime,
});
expect(updatedSchedule?.lastRunTriggeredAt).toBeTruthy();
expect(updatedSchedule?.lastRunTriggeredAt).toBeInstanceOf(Date);
const finalInstance = await prisma.taskScheduleInstance.findFirst({
where: { id: scheduleInstance.id },
});
// After two executions, lastScheduledTimestamp should be the second execution time
if (actualNextExecution && secondExecution.params.exactScheduleTime) {
const secondExecutionTime = secondExecution.params.exactScheduleTime;
expect(finalInstance?.lastScheduledTimestamp).toEqual(secondExecutionTime);
// The next scheduled timestamp should be 1 minute after the second execution
const expectedThirdExecution = new Date(secondExecutionTime);
expectedThirdExecution.setMinutes(secondExecutionTime.getMinutes() + 1);
expect(finalInstance?.nextScheduledTimestamp).toEqual(expectedThirdExecution);
}
} finally {
// Clean up: stop the worker
await engine.quit();
}
}
);
// Deploy-moment backward compatibility. At deploy time, in-flight Redis jobs
// were enqueued by the old engine — their payload has no `lastScheduleTime`
// field — and `instance.lastScheduledTimestamp` is still populated (last
// written by the old engine pre-deploy). The new engine must report that DB
// value as `payload.lastTimestamp` so customers don't see a transient
// `undefined` for the one fire per schedule that drains the legacy queue.
containerTest(
"should fall back to instance.lastScheduledTimestamp when payload lacks lastScheduleTime",
{ timeout: 30_000 },
async ({ prisma, redisOptions }) => {
const triggerCalls: TriggerScheduledTaskParams[] = [];
const engine = new ScheduleEngine({
prisma,
redis: redisOptions,
distributionWindow: { seconds: 10 },
worker: {
concurrency: 1,
disabled: true, // Don't actually run the worker — calling triggerScheduledTask directly
pollIntervalMs: 1000,
},
tracer: trace.getTracer("test", "0.0.0"),
onTriggerScheduledTask: async (params) => {
triggerCalls.push(params);
return { success: true };
},
isDevEnvironmentConnectedHandler: vi.fn().mockResolvedValue(true),
});
try {
const organization = await prisma.organization.create({
data: { title: "Legacy Payload Org", slug: "legacy-payload-org" },
});
const project = await prisma.project.create({
data: {
name: "Legacy Payload Project",
slug: "legacy-payload-project",
externalRef: "legacy-payload-ref",
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: "legacy-payload-env",
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: "tr_legacy_1234",
pkApiKey: "pk_legacy_1234",
shortcode: "legacy-short",
},
});
const taskSchedule = await prisma.taskSchedule.create({
data: {
friendlyId: "sched_legacy_payload",
taskIdentifier: "legacy-payload-task",
projectId: project.id,
deduplicationKey: "legacy-payload-dedup",
userProvidedDeduplicationKey: false,
generatorExpression: "*/5 * * * *",
generatorDescription: "Every 5 minutes",
timezone: "UTC",
type: "DECLARATIVE",
active: true,
externalId: "legacy-ext",
},
});
// Pre-populate lastScheduledTimestamp on the instance — simulates the
// value the old engine wrote to the DB before this PR deployed.
const preDeployLastFire = new Date("2026-04-30T10:00:00.000Z");
const scheduleInstance = await prisma.taskScheduleInstance.create({
data: {
taskScheduleId: taskSchedule.id,
environmentId: environment.id,
projectId: project.id,
active: true,
lastScheduledTimestamp: preDeployLastFire,
},
});
// Call triggerScheduledTask directly without lastScheduleTime,
// simulating an in-flight Redis job enqueued by the old engine.
const exactScheduleTime = new Date("2026-04-30T10:05:00.000Z");
await engine.triggerScheduledTask({
instanceId: scheduleInstance.id,
finalAttempt: false,
exactScheduleTime,
// lastScheduleTime intentionally omitted — legacy payload shape
});
expect(triggerCalls.length).toBe(1);
expect(triggerCalls[0].payload.timestamp).toEqual(exactScheduleTime);
// Falls back to instance.lastScheduledTimestamp from the DB rather
// than reporting undefined for this one transitional fire.
expect(triggerCalls[0].payload.lastTimestamp).toEqual(preDeployLastFire);
} finally {
await engine.quit();
}
}
);
});
@@ -93,18 +93,14 @@ describe("Schedule Recovery", () => {
// Perform recovery
await engine.recoverSchedulesInEnvironment(project.id, environment.id);
// Verify that a job was created
// Verify that a job was created. The engine no longer persists
// nextScheduledTimestamp; correctness is now determined entirely by the
// job sitting in the worker queue.
const jobAfterRecovery = await engine.getJob(
`scheduled-task-instance:${scheduleInstance.id}`
);
expect(jobAfterRecovery).not.toBeNull();
expect(jobAfterRecovery?.job).toBe("schedule.triggerScheduledTask");
// Verify the instance was updated with next scheduled timestamp
const updatedInstance = await prisma.taskScheduleInstance.findFirst({
where: { id: scheduleInstance.id },
});
expect(updatedInstance?.nextScheduledTimestamp).toBeDefined();
} finally {
await engine.quit();
}
@@ -313,20 +309,14 @@ describe("Schedule Recovery", () => {
// Perform recovery
await engine.recoverSchedulesInEnvironment(project.id, environment.id);
// Verify that jobs were created for all instances
// Verify that jobs were created for all instances. The engine no longer
// persists nextScheduledTimestamp — the worker-queue presence is the
// source of truth.
for (const instance of instances) {
const job = await engine.getJob(`scheduled-task-instance:${instance.id}`);
expect(job).not.toBeNull();
expect(job?.job).toBe("schedule.triggerScheduledTask");
}
// Verify all instances were updated
for (const instance of instances) {
const updatedInstance = await prisma.taskScheduleInstance.findFirst({
where: { id: instance.id },
});
expect(updatedInstance?.nextScheduledTimestamp).toBeDefined();
}
} finally {
await engine.quit();
}
@@ -396,4 +386,194 @@ describe("Schedule Recovery", () => {
}
}
);
// External-caller backward-compat. Deploy sync (`syncDeclarativeSchedules`)
// and schedule upsert both call `registerNextTaskScheduleInstance` with no
// `lastScheduleTime`. They run on every app deploy and on every cron edit.
// For an existing-and-firing schedule, the call must NOT clobber the
// worker payload's `lastScheduleTime` with `undefined` — otherwise the
// next fire would surface a stale frozen DB-column value to the customer
// (since this PR stops writing that column). The function must derive a
// sensible `lastScheduleTime` from the cron expression's previous slot
// when the caller doesn't pass one.
containerTest(
"should derive lastScheduleTime from cron when external callers omit it",
{ timeout: 30_000 },
async ({ prisma, redisOptions }) => {
const engine = new ScheduleEngine({
prisma,
redis: redisOptions,
distributionWindow: { seconds: 10 },
worker: { concurrency: 1, disabled: true, pollIntervalMs: 1000 },
tracer: trace.getTracer("test", "0.0.0"),
onTriggerScheduledTask: async () => ({ success: true }),
isDevEnvironmentConnectedHandler: vi.fn().mockResolvedValue(true),
});
try {
const organization = await prisma.organization.create({
data: { title: "External Caller Org", slug: "external-caller-org" },
});
const project = await prisma.project.create({
data: {
name: "External Caller Project",
slug: "external-caller-project",
externalRef: "external-caller-ref",
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: "external-caller-env",
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: "tr_external_1234",
pkApiKey: "pk_external_1234",
shortcode: "external-short",
},
});
const taskSchedule = await prisma.taskSchedule.create({
data: {
friendlyId: "sched_external_caller",
taskIdentifier: "external-caller-task",
projectId: project.id,
deduplicationKey: "external-caller-dedup",
userProvidedDeduplicationKey: false,
generatorExpression: "*/5 * * * *",
generatorDescription: "Every 5 minutes",
timezone: "UTC",
type: "DECLARATIVE",
active: true,
},
});
// Backdate the instance so the cron's previous slot postdates
// createdAt — this simulates a long-running schedule, the case
// Devin flagged (deploy clobbers lastScheduleTime, post-deploy fire
// would otherwise read from a frozen DB column).
const longAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const scheduleInstance = await prisma.taskScheduleInstance.create({
data: {
taskScheduleId: taskSchedule.id,
environmentId: environment.id,
projectId: project.id,
active: true,
},
});
await prisma.taskScheduleInstance.update({
where: { id: scheduleInstance.id },
data: { createdAt: longAgo },
});
// External-caller pattern — no lastScheduleTime.
await engine.registerNextTaskScheduleInstance({
instanceId: scheduleInstance.id,
});
const job = await engine.getJob(`scheduled-task-instance:${scheduleInstance.id}`);
expect(job).not.toBeNull();
// The function should have derived lastScheduleTime from cron,
// putting a real timestamp into the worker payload rather than
// undefined. The Redis worker stores payloads as JSON, so the value
// is a string when read back here — Zod re-coerces it to Date on
// dequeue (workerCatalog uses `z.coerce.date()`).
const enqueuedLastScheduleTime = (job?.item as { lastScheduleTime?: string })
.lastScheduleTime;
expect(enqueuedLastScheduleTime).toBeDefined();
const derived = new Date(enqueuedLastScheduleTime!);
// The derived value should match the cron's previous slot — for
// `*/5 * * * *`, a 5-minute boundary in the recent past.
expect(derived.getTime()).toBeLessThan(Date.now());
expect(derived.getUTCSeconds()).toBe(0);
expect(derived.getUTCMinutes() % 5).toBe(0);
} finally {
await engine.quit();
}
}
);
// Brand-new schedules must NOT receive a cron-derived lastScheduleTime —
// the cron's previous slot predates the instance, so it's not a real
// previous fire. The first-run sentinel (`if (!payload.lastTimestamp)`)
// must keep working.
containerTest(
"should leave lastScheduleTime undefined for brand-new schedules",
{ timeout: 30_000 },
async ({ prisma, redisOptions }) => {
const engine = new ScheduleEngine({
prisma,
redis: redisOptions,
distributionWindow: { seconds: 10 },
worker: { concurrency: 1, disabled: true, pollIntervalMs: 1000 },
tracer: trace.getTracer("test", "0.0.0"),
onTriggerScheduledTask: async () => ({ success: true }),
isDevEnvironmentConnectedHandler: vi.fn().mockResolvedValue(true),
});
try {
const organization = await prisma.organization.create({
data: { title: "Brand New Org", slug: "brand-new-org" },
});
const project = await prisma.project.create({
data: {
name: "Brand New Project",
slug: "brand-new-project",
externalRef: "brand-new-ref",
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: "brand-new-env",
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: "tr_brandnew_1234",
pkApiKey: "pk_brandnew_1234",
shortcode: "brandnew-short",
},
});
const taskSchedule = await prisma.taskSchedule.create({
data: {
friendlyId: "sched_brand_new",
taskIdentifier: "brand-new-task",
projectId: project.id,
deduplicationKey: "brand-new-dedup",
userProvidedDeduplicationKey: false,
// Hourly cron — the previous slot is plausibly minutes-to-an-hour
// ago, comfortably predating an instance just created.
generatorExpression: "0 * * * *",
generatorDescription: "Hourly",
timezone: "UTC",
type: "DECLARATIVE",
active: true,
},
});
const scheduleInstance = await prisma.taskScheduleInstance.create({
data: {
taskScheduleId: taskSchedule.id,
environmentId: environment.id,
projectId: project.id,
active: true,
},
});
await engine.registerNextTaskScheduleInstance({
instanceId: scheduleInstance.id,
});
const job = await engine.getJob(`scheduled-task-instance:${scheduleInstance.id}`);
expect(job).not.toBeNull();
const enqueuedLastScheduleTime = (job?.item as { lastScheduleTime?: Date }).lastScheduleTime;
// Brand-new schedule: cron's previous slot predates instance.createdAt,
// so the function leaves lastScheduleTime undefined — the first fire
// will report `payload.lastTimestamp: undefined` and customer first-run
// sentinel patterns keep working.
expect(enqueuedLastScheduleTime).toBeUndefined();
} finally {
await engine.quit();
}
}
);
});
+19
View File
@@ -2828,6 +2828,25 @@ importers:
specifier: 5.5.4
version: 5.5.4
references/scheduled-tasks:
dependencies:
'@trigger.dev/sdk':
specifier: workspace:*
version: link:../../packages/trigger-sdk
zod:
specifier: 3.25.76
version: 3.25.76
devDependencies:
'@trigger.dev/build':
specifier: workspace:*
version: link:../../packages/build
trigger.dev:
specifier: workspace:*
version: link:../../packages/cli-v3
typescript:
specifier: 5.5.4
version: 5.5.4
references/seed:
dependencies:
'@sinclair/typebox':
+39
View File
@@ -0,0 +1,39 @@
# scheduled-tasks reference
E2E test bed for the schedule engine. Designed to verify the worker-payload flow
that carries `payload.lastTimestamp` forward across fires (no DB round-trip,
no cron-derivation drift).
## Setup
1. Create a project in the local webapp at http://localhost:3030 and copy the
project ref from Project Settings.
2. Replace `proj_REPLACE_ME` in `trigger.config.ts` with that ref.
3. From this directory:
```bash
pnpm exec trigger login -a http://localhost:3030 --profile local
pnpm exec trigger dev --profile local
```
4. Open the project in the dashboard and visit each schedule. Click "Attach to
environment" → dev. The first fire happens at the next cron slot.
## What to look for
- **`first-fire-detector`** — first run of a freshly-attached schedule should
log `first-fire-detector PASS (first fire)` with `lastTimestamp: null`.
Subsequent runs log `PASS (Nth fire)` with a Date.
- **`interval-validator`** — every non-first fire of an every-minute task
should log `interval-validator PASS` with `actualIntervalMs: 60000`. A
`FAIL` here means the worker payload isn't carrying the previous fire time
correctly.
- **`upcoming-validator`** — every fire should log `upcoming-validator PASS`
with 10 strictly-increasing slots, each 60s apart.
- **`every-minute`**, **`every-five-minutes`**, **`hourly-utc`** — sanity
checks across cadences. Inspect `payload` in the dashboard to confirm
`timestamp` and `lastTimestamp` look right.
- **`daily-*`** schedules — won't fire during a short dev session, but the
attach action should enqueue a Redis job for the next slot in the listed
timezone. Worth checking that next-fire time matches the expected wall-clock
in that tz.
+18
View File
@@ -0,0 +1,18 @@
{
"name": "references-scheduled-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "trigger dev",
"deploy": "trigger deploy"
},
"dependencies": {
"@trigger.dev/sdk": "workspace:*",
"zod": "3.25.76"
},
"devDependencies": {
"@trigger.dev/build": "workspace:*",
"trigger.dev": "workspace:*",
"typescript": "^5.5.4"
}
}
@@ -0,0 +1,216 @@
import { logger, schedules } from "@trigger.dev/sdk";
/**
* Reference project for E2E-testing the schedule engine.
*
* The schedule engine carries the previous fire time forward via the worker
* queue payload (no DB round-trip). These tasks exercise the customer-visible
* surface so we can verify the flow end-to-end:
*
* - First-ever fire reports `payload.lastTimestamp === undefined`.
* - Subsequent fires report `payload.lastTimestamp` equal to the previous
* `payload.timestamp` exactly (no cron-derivation drift).
* - `payload.upcoming` is a strictly-increasing array of 10 future slots.
* - Multiple cron syntaxes and timezones all behave consistently.
*
* Validators (every-minute, interval, upcoming) emit explicit PASS / FAIL
* log lines so you can grep `trigger dev` output for regressions.
*/
// --- Basic recurring tasks ----------------------------------------------------
export const everyMinute = schedules.task({
id: "every-minute",
cron: "* * * * *",
run: async (payload) => {
logger.info("every-minute fired", {
timestamp: payload.timestamp.toISOString(),
lastTimestamp: payload.lastTimestamp?.toISOString() ?? null,
upcomingCount: payload.upcoming.length,
timezone: payload.timezone,
scheduleId: payload.scheduleId,
});
return {
timestamp: payload.timestamp,
lastTimestamp: payload.lastTimestamp,
};
},
});
export const everyFiveMinutes = schedules.task({
id: "every-five-minutes",
cron: "*/5 * * * *",
run: async (payload) => {
logger.info("every-five-minutes fired", {
timestamp: payload.timestamp.toISOString(),
lastTimestamp: payload.lastTimestamp?.toISOString() ?? null,
});
},
});
export const hourlyUtc = schedules.task({
id: "hourly-utc",
cron: "0 * * * *",
run: async (payload) => {
logger.info("hourly-utc fired", {
timestamp: payload.timestamp.toISOString(),
lastTimestamp: payload.lastTimestamp?.toISOString() ?? null,
timezone: payload.timezone,
});
},
});
// --- Timezone coverage --------------------------------------------------------
//
// These exercise the engine's tz handling. They fire infrequently, so they're
// mostly useful for inspecting enqueued jobs in the dashboard rather than for
// short dev sessions.
export const dailyNewYorkMorning = schedules.task({
id: "daily-new-york-morning",
cron: { pattern: "0 9 * * *", timezone: "America/New_York" },
run: async (payload) => {
logger.info("daily-new-york-morning fired", {
timestamp: payload.timestamp.toISOString(),
lastTimestamp: payload.lastTimestamp?.toISOString() ?? null,
timezone: payload.timezone,
// For DST-observing tz the cron interpretation may shift twice/year —
// worth eyeballing the timestamp lines up with 09:00 NY local.
});
},
});
export const dailyLondonEvening = schedules.task({
id: "daily-london-evening",
cron: { pattern: "0 18 * * *", timezone: "Europe/London" },
run: async (payload) => {
logger.info("daily-london-evening fired", {
timestamp: payload.timestamp.toISOString(),
lastTimestamp: payload.lastTimestamp?.toISOString() ?? null,
timezone: payload.timezone,
});
},
});
export const dailyTokyoMidnight = schedules.task({
id: "daily-tokyo-midnight",
cron: { pattern: "0 0 * * *", timezone: "Asia/Tokyo" },
run: async (payload) => {
logger.info("daily-tokyo-midnight fired", {
timestamp: payload.timestamp.toISOString(),
lastTimestamp: payload.lastTimestamp?.toISOString() ?? null,
timezone: payload.timezone,
});
},
});
// --- Validators ---------------------------------------------------------------
//
// These explicitly check invariants and emit PASS / FAIL log lines. Running
// `trigger dev` and watching these for several fires gives us the E2E signal
// that the worker-payload flow is correct.
/**
* Verifies that the very first fire reports `lastTimestamp === undefined` and
* subsequent fires report a real Date. This is the customer-visible surface
* for the "first-run sentinel" pattern: `if (!payload.lastTimestamp) initOnce()`.
*/
export const firstFireDetector = schedules.task({
id: "first-fire-detector",
cron: "* * * * *",
run: async (payload) => {
const isFirstFire = payload.lastTimestamp === undefined;
logger.info(
isFirstFire ? "first-fire-detector PASS (first fire)" : "first-fire-detector PASS (Nth fire)",
{
timestamp: payload.timestamp.toISOString(),
lastTimestamp: payload.lastTimestamp?.toISOString() ?? null,
isFirstFire,
}
);
},
});
/**
* Verifies that for non-first fires, `timestamp - lastTimestamp` equals exactly
* the cron interval (60s for every-minute). This is the key invariant of the
* workerCatalog approach — the value is carried through the Redis payload, so
* it should be exact, not a cron-derived approximation.
*/
export const intervalValidator = schedules.task({
id: "interval-validator",
cron: "* * * * *",
run: async (payload) => {
if (payload.lastTimestamp === undefined) {
logger.info("interval-validator skipped (first fire — no lastTimestamp)", {
timestamp: payload.timestamp.toISOString(),
});
return;
}
const expectedIntervalMs = 60_000;
const actualIntervalMs = payload.timestamp.getTime() - payload.lastTimestamp.getTime();
const passed = actualIntervalMs === expectedIntervalMs;
logger.info(passed ? "interval-validator PASS" : "interval-validator FAIL", {
timestamp: payload.timestamp.toISOString(),
lastTimestamp: payload.lastTimestamp.toISOString(),
expectedIntervalMs,
actualIntervalMs,
driftMs: actualIntervalMs - expectedIntervalMs,
});
if (!passed) {
throw new Error(
`interval-validator FAIL: expected ${expectedIntervalMs}ms between fires, got ${actualIntervalMs}ms`
);
}
},
});
/**
* Verifies that `payload.upcoming` is a strictly-increasing array of 10 future
* slots, each 60s apart for `* * * * *`.
*/
export const upcomingValidator = schedules.task({
id: "upcoming-validator",
cron: "* * * * *",
run: async (payload) => {
const issues: string[] = [];
if (payload.upcoming.length !== 10) {
issues.push(`expected upcoming.length === 10, got ${payload.upcoming.length}`);
}
for (let i = 0; i < payload.upcoming.length; i++) {
const slot = payload.upcoming[i];
if (slot.getTime() <= payload.timestamp.getTime()) {
issues.push(`upcoming[${i}] (${slot.toISOString()}) is not strictly after timestamp`);
}
if (i > 0) {
const prev = payload.upcoming[i - 1];
const gapMs = slot.getTime() - prev.getTime();
if (gapMs !== 60_000) {
issues.push(
`upcoming[${i}] - upcoming[${i - 1}] = ${gapMs}ms, expected 60000ms (every-minute cron)`
);
}
}
}
if (issues.length === 0) {
logger.info("upcoming-validator PASS", {
timestamp: payload.timestamp.toISOString(),
upcoming: payload.upcoming.map((d) => d.toISOString()),
});
} else {
logger.error("upcoming-validator FAIL", {
timestamp: payload.timestamp.toISOString(),
issues,
upcoming: payload.upcoming.map((d) => d.toISOString()),
});
throw new Error(`upcoming-validator FAIL: ${issues.join("; ")}`);
}
},
});
@@ -0,0 +1,21 @@
import { defineConfig } from "@trigger.dev/sdk";
export default defineConfig({
project: "proj_qcibamtpklwidqfzzyir",
runtime: "node",
machine: "small-1x",
maxDuration: 60,
dirs: ["./src/trigger"],
logLevel: "debug",
compatibilityFlags: ["run_engine_v2"],
retries: {
enabledInDev: true,
default: {
maxAttempts: 3,
minTimeoutInMs: 1000,
maxTimeoutInMs: 10000,
factor: 2,
randomize: true,
},
},
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"customConditions": ["@triggerdotdev/source"],
"lib": ["DOM", "DOM.Iterable"],
"noEmit": true
},
"include": ["./src/**/*.ts", "trigger.config.ts"]
}