import { type BatchTaskRunStatus, type Prisma } from "@trigger.dev/database"; import { type PrismaClientOrTransaction, type PrismaReplicaClient } from "~/db.server"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; import { engine } from "~/v3/runEngine.server"; import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server"; import { BasePresenter } from "./basePresenter.server"; type BatchPresenterOptions = { environmentId: string; batchId: string; userId?: string; }; // Shared by the read-through closures and the passthrough so every store path returns // a byte-identical row shape. const BATCH_SELECT = { id: true, friendlyId: true, status: true, runCount: true, batchVersion: true, createdAt: true, updatedAt: true, completedAt: true, processingStartedAt: true, processingCompletedAt: true, successfulRunCount: true, failedRunCount: true, idempotencyKey: true, errors: { select: { id: true, index: true, taskIdentifier: true, error: true, errorCode: true, createdAt: true, }, orderBy: { index: "asc", }, }, } satisfies Prisma.BatchTaskRunSelect; type BatchRow = Prisma.BatchTaskRunGetPayload<{ select: typeof BATCH_SELECT }>; type BatchPresenterDeps = { /** Resolved boot constant; never awaited per-request when supplied. */ splitEnabled?: boolean; newClient?: PrismaReplicaClient; legacyReplica?: PrismaReplicaClient; readThrough?: typeof readThroughRun; resolveDisplayableEnvironment?: typeof findDisplayableEnvironment; }; export type BatchPresenterData = Awaited>; export class BatchPresenter extends BasePresenter { constructor( _prisma?: PrismaClientOrTransaction, _replica?: PrismaClientOrTransaction, private readonly deps: BatchPresenterDeps = {} ) { super(_prisma, _replica); } public async call({ environmentId, batchId, userId }: BatchPresenterOptions) { // Reads the BatchTaskRun (run-ops) via the read-through layer: split on -> new run-ops // first, then the LEGACY RUN-OPS READ REPLICA only for not-yet-migrated batches (never the // legacy primary); split off (single-DB / self-host) -> one plain batchTaskRun.findFirst // (passthrough). The runtimeEnvironment (control-plane) is resolved separately because its // FK is physically dropped on cloud, so a batch row on the new run-ops DB cannot single-SQL // join to control-plane RuntimeEnvironment. const where = { runtimeEnvironmentId: environmentId, friendlyId: batchId } as const; const readBatch = (client: PrismaReplicaClient): Promise => client.batchTaskRun.findFirst({ select: BATCH_SELECT, where }); const readThrough = this.deps.readThrough ?? readThroughRun; const batchResult = await readThrough({ // The read-through key; here it is the batch friendlyId. A cuid-shaped batch friendlyId // classifies as LEGACY and the read-through probes both stores (new first, then legacy // replica); a run-ops-shaped one (cut-over orgs) classifies as NEW and reads only the new // store — either way the row is found on the DB that owns it. runId: batchId, environmentId, readNew: readBatch, readLegacy: readBatch, deps: { splitEnabled: this.deps.splitEnabled, newClient: this.deps.newClient, legacyReplica: this.deps.legacyReplica, }, }); const batch = batchResult.source === "new" || batchResult.source === "legacy-replica" ? batchResult.value : null; // not-found / past-retention => normal not-found surface if (!batch) { throw new Error("Batch not found"); } const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING"; const isV2 = batch.batchVersion === "runengine:v2"; // For v2 batches in PROCESSING state, get live progress from Redis // This provides real-time updates without waiting for the batch to complete let liveSuccessCount = batch.successfulRunCount ?? 0; let liveFailureCount = batch.failedRunCount ?? 0; if (isV2 && batch.status === "PROCESSING") { const liveProgress = await engine.getBatchQueueProgress(batch.id); if (liveProgress) { liveSuccessCount = liveProgress.successCount; liveFailureCount = liveProgress.failureCount; } } // Control-plane env resolved separately from the run-ops batch row (cross-seam FK dropped). const resolveEnv = this.deps.resolveDisplayableEnvironment ?? findDisplayableEnvironment; return { id: batch.id, friendlyId: batch.friendlyId, status: batch.status as BatchTaskRunStatus, runCount: batch.runCount, batchVersion: batch.batchVersion, isV2, createdAt: batch.createdAt.toISOString(), updatedAt: batch.updatedAt.toISOString(), completedAt: batch.completedAt?.toISOString(), processingStartedAt: batch.processingStartedAt?.toISOString(), processingCompletedAt: batch.processingCompletedAt?.toISOString(), finishedAt: batch.completedAt ? batch.completedAt.toISOString() : hasFinished ? batch.updatedAt.toISOString() : undefined, hasFinished, successfulRunCount: liveSuccessCount, failedRunCount: liveFailureCount, idempotencyKey: batch.idempotencyKey, environment: await resolveEnv(environmentId, userId), errors: batch.errors.map((error) => ({ id: error.id, index: error.index, taskIdentifier: error.taskIdentifier, error: error.error, errorCode: error.errorCode, createdAt: error.createdAt.toISOString(), })), }; } }