feat(run-ops): read presenters — de-join control-plane relations + read-through hydration (#4122)

This commit is contained in:
Daniel Sutton
2026-07-03 20:37:59 +01:00
committed by GitHub
parent 5be6a4fe38
commit 0a51341347
40 changed files with 8533 additions and 679 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Route dashboard and API run/batch/waitpoint presenter reads through the run store so they can be served from a separate backing store without changing call sites.
@@ -1,81 +1,219 @@
import type { BatchTaskRunExecutionResult } from "@trigger.dev/core/v3";
import {
$replica,
type PrismaClientOrTransaction,
type PrismaReplicaClient,
prisma,
} from "~/db.server";
import type { TaskRunWithAttempts } from "~/models/taskRun.server";
import { executionResultForTaskRun } from "~/models/taskRun.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { runStore } from "~/v3/runStore.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
import { runStore as defaultRunStore } from "~/v3/runStore.server";
import { BasePresenter } from "./basePresenter.server";
/**
* Run-ops read-through wiring. All optional; absent (or `splitEnabled` falsy) collapses `call` to
* passthrough. `legacyReplica` is a READ REPLICA handle only — there is NO legacy-primary field.
*/
type ApiBatchResultsReadThroughDeps = {
splitEnabled?: boolean;
newClient?: PrismaReplicaClient;
legacyReplica?: PrismaReplicaClient;
isPastRetention?: (runId: string) => boolean;
};
// The TaskRun shape `executionResultForTaskRun` consumes. Shared by both read sites.
const memberRunSelect = {
id: true,
friendlyId: true,
status: true,
taskIdentifier: true,
attempts: {
select: {
status: true,
output: true,
outputType: true,
error: true,
},
orderBy: {
createdAt: "desc",
},
},
} as const;
/**
* Split on: the batch row + its item rows resolve new-run-ops first, then the LEGACY RUN-OPS
* READ REPLICA ONLY (never the legacy primary — there is no such handle); each member run is
* hydrated independently via readThroughRun keyed on the member runId, so a batch whose members
* span migrated + abandoned runs returns the complete reachable set (the batch-spanning-the-line
* read; the dangling-reference termination gate is a separate, adjacent unit).
*
* Split off (single-DB / self-host): one passthrough read for the batch row + a single store
* id-set hydrate for the members — no legacy read, no known-migrated probe, no second connection.
*/
export class ApiBatchResultsPresenter extends BasePresenter {
constructor(
prismaClient: PrismaClientOrTransaction = prisma,
replicaClient: PrismaClientOrTransaction = $replica,
private readonly readThrough?: ApiBatchResultsReadThroughDeps,
private readonly runStore = defaultRunStore
) {
super(prismaClient, replicaClient);
}
public async call(
friendlyId: string,
env: AuthenticatedEnvironment
): Promise<BatchTaskRunExecutionResult | undefined> {
return this.traceWithEnv("call", env, async (span) => {
// Route through the store so a NEW-resident batch resolves under the run-ops split (the
// router probes NEW→LEGACY and drops this client hint) instead of 404ing on a control-plane read.
const batchRun = await runStore.findBatchTaskRunByFriendlyId(
friendlyId,
env.id,
{
include: {
items: {
select: {
taskRunId: true,
},
},
},
},
this._prisma
);
const splitEnabled = this.readThrough?.splitEnabled ?? false;
if (!batchRun) {
return undefined;
if (!splitEnabled) {
return this.#callPassthrough(friendlyId, env);
}
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
if (taskRunIds.length === 0) {
return {
id: batchRun.friendlyId,
items: [],
};
}
const taskRuns = await runStore.findRuns(
{
where: { id: { in: taskRunIds } },
select: {
id: true,
friendlyId: true,
status: true,
taskIdentifier: true,
attempts: {
select: {
status: true,
output: true,
outputType: true,
error: true,
},
orderBy: {
createdAt: "desc",
},
},
},
},
this._prisma
);
const runMap = new Map(taskRuns.map((run) => [run.id, run]));
return {
id: batchRun.friendlyId,
items: batchRun.items
.map((item) => {
const run = runMap.get(item.taskRunId);
return run ? executionResultForTaskRun(run as TaskRunWithAttempts) : undefined;
})
.filter(Boolean),
};
return this.#callSplit(friendlyId, env);
});
}
// Passthrough: batch row off the replica, members via the single run store. No legacy read.
async #callPassthrough(
friendlyId: string,
env: AuthenticatedEnvironment
): Promise<BatchTaskRunExecutionResult | undefined> {
const batchRun = await this._replica.batchTaskRun.findFirst({
where: {
friendlyId,
runtimeEnvironmentId: env.id,
},
include: {
items: {
select: {
taskRunId: true,
},
},
},
});
if (!batchRun) {
return undefined;
}
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
if (taskRunIds.length === 0) {
return {
id: batchRun.friendlyId,
items: [],
};
}
const taskRuns = await this.runStore.findRuns(
{
where: { id: { in: taskRunIds } },
select: memberRunSelect,
},
this._prisma
);
const runMap = new Map(taskRuns.map((run) => [run.id, run]));
return {
id: batchRun.friendlyId,
items: batchRun.items
.map((item) => {
const run = runMap.get(item.taskRunId);
return run ? executionResultForTaskRun(run as TaskRunWithAttempts) : undefined;
})
.filter(Boolean),
};
}
// Split: resolve the batch row new-first then off the legacy READ REPLICA only (a batch id may
// be cuid or ksuid, and a cuid-shaped id can still have been backfilled onto NEW, so id-shape
// residency is not authoritative for the row — the new-first-then-legacy probe is), then
// hydrate every member run independently via the per-run read-through primitive.
async #callSplit(
friendlyId: string,
env: AuthenticatedEnvironment
): Promise<BatchTaskRunExecutionResult | undefined> {
// Resolve both handles ONCE so the batch row and its members never read from different DBs.
const newClient = (this.readThrough?.newClient ?? this._replica) as PrismaReplicaClient;
const legacyReplica = (this.readThrough?.legacyReplica ?? this._replica) as PrismaReplicaClient;
const readBatch = (client: PrismaClientOrTransaction) =>
client.batchTaskRun.findFirst({
where: {
friendlyId,
runtimeEnvironmentId: env.id,
},
include: {
items: {
select: {
taskRunId: true,
},
},
},
});
let batchRun = await readBatch(newClient);
// Legacy READ REPLICA probe, only on a new-probe miss; skipped when past retention.
if (!batchRun && !this.readThrough?.isPastRetention?.(friendlyId)) {
batchRun = await readBatch(legacyReplica);
}
if (!batchRun) {
return undefined;
}
if (batchRun.items.length === 0) {
return {
id: batchRun.friendlyId,
items: [],
};
}
const readMemberRun = (client: PrismaClientOrTransaction, taskRunId: string) =>
client.taskRun.findFirst({
where: { id: taskRunId },
select: memberRunSelect,
}) as Promise<TaskRunWithAttempts | null>;
// Per-member fan-out: each member may live on a different DB, so a single nested include cannot
// cross the seam. Promise.all preserves batchRun.items order, unchanged from today.
const memberResults = await Promise.all(
batchRun.items.map(async (item) => {
const result = await readThroughRun<TaskRunWithAttempts>({
runId: item.taskRunId,
environmentId: env.id,
readNew: (client) => readMemberRun(client, item.taskRunId),
readLegacy: (replica) => readMemberRun(replica, item.taskRunId),
deps: {
splitEnabled: true,
// Pass the SAME resolved handles the batch row used, so the batch row and its members
// never resolve against different DBs. (Letting these fall through to readThroughRun's
// own module-level defaults would diverge from the batch read's `?? this._replica`.)
newClient,
legacyReplica,
isPastRetention: this.readThrough?.isPastRetention,
},
});
// not-found / past-retention members are omitted (matches today's drop-undefined behavior);
// the dangling-reference termination gate (separate unit) governs whether that's permitted.
if (result.source === "not-found" || result.source === "past-retention") {
return undefined;
}
return executionResultForTaskRun(result.value);
})
);
return {
id: batchRun.friendlyId,
items: memberResults.filter(Boolean),
};
}
}
@@ -26,10 +26,10 @@ import {
} from "~/v3/mollifier/readFallback.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
import { runStore } from "~/v3/runStore.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { tracer } from "~/v3/tracer.server";
import { startSpanWithEnv } from "~/v3/tracing.server";
// Build 'select' object
const commonRunSelect = {
id: true,
friendlyId: true,
@@ -54,11 +54,7 @@ const commonRunSelect = {
scheduleId: true,
workerQueue: true,
region: true,
lockedToVersion: {
select: {
version: true,
},
},
lockedToVersionId: true,
resumeParentOnCompletion: true,
batch: {
select: {
@@ -75,14 +71,19 @@ type CommonRelatedRun = Prisma.Result<
"findFirstOrThrow"
>;
// commonRunSelect carries only the scalar `lockedToVersionId`; `version` is resolved via the
// control-plane resolver and folded back on for the run and each related run, since
// `createCommonRunStructure` reads `.lockedToVersion?.version` for all of them.
type CommonRelatedRunWithVersion = CommonRelatedRun & {
lockedToVersion: { version: string } | null;
};
// Full shape returned by findRun() — the commonRunSelect fields plus the
// extras the route handler reads. Declared explicitly (not inferred via
// ReturnType<typeof findRun>) so findRun can return a synthesised buffered
// run without the type becoming self-referential.
// Exported so the buffer-synthesis helper below can be unit-tested
// against a stable shape without re-deriving it (FoundRun's exact field
// list is what the buffered run must match for `call()` not to surprise).
export type FoundRun = CommonRelatedRun & {
// run without the type becoming self-referential. Exported so the
// buffer-synthesis helper below can match this shape under unit test.
export type FoundRun = CommonRelatedRunWithVersion & {
traceId: string;
payload: string;
payloadType: string;
@@ -93,17 +94,14 @@ export type FoundRun = CommonRelatedRun & {
attemptNumber: number | null;
engine: "V1" | "V2";
taskEventStore: string;
parentTaskRun: CommonRelatedRun | null;
rootTaskRun: CommonRelatedRun | null;
childRuns: CommonRelatedRun[];
parentTaskRun: CommonRelatedRunWithVersion | null;
rootTaskRun: CommonRelatedRunWithVersion | null;
childRuns: CommonRelatedRunWithVersion[];
// True when this run was synthesised from the mollifier buffer rather
// than read from Postgres. Callers that would otherwise query backing
// stores keyed on PG identifiers (e.g. ClickHouse event lookups by
// traceId) can short-circuit to an empty response — buffered runs
// haven't executed and have no events to fetch. Devin's analysis on
// PR #3755 (events endpoint) flagged the pre-fix code as making a
// wasted ClickHouse round-trip when this is set; gate on this flag
// instead.
// haven't executed and have no events to fetch.
isBuffered: boolean;
};
@@ -150,7 +148,57 @@ export class ApiRetrieveRunPresenter {
$replica
);
if (pgRow) return { ...pgRow, isBuffered: false };
if (pgRow) {
// Dedup distinct locked-version ids so each hits the resolver exactly once
// (unbounded childRuns mostly share the same lockedToVersionId).
const distinctVersionIds = new Set<string>();
const collect = (id: string | null) => {
if (id) {
distinctVersionIds.add(id);
}
};
collect(pgRow.lockedToVersionId);
collect(pgRow.parentTaskRun?.lockedToVersionId ?? null);
collect(pgRow.rootTaskRun?.lockedToVersionId ?? null);
for (const child of pgRow.childRuns) {
collect(child.lockedToVersionId);
}
const versionById = new Map<string, string | null>(
await Promise.all(
[...distinctVersionIds].map(
async (id) =>
[
id,
(await controlPlaneResolver.resolveRunLockedWorker({ lockedToVersionId: id }))
?.lockedToVersion?.version ?? null,
] as const
)
)
);
const resolveVersion = (id: string | null): { version: string } | null => {
if (!id) {
return null;
}
const version = versionById.get(id) ?? null;
return version !== null ? { version } : null;
};
const foldVersion = (run: CommonRelatedRun): CommonRelatedRunWithVersion => ({
...run,
lockedToVersion: resolveVersion(run.lockedToVersionId),
});
return {
...pgRow,
lockedToVersion: resolveVersion(pgRow.lockedToVersionId),
parentTaskRun: pgRow.parentTaskRun ? foldVersion(pgRow.parentTaskRun) : null,
rootTaskRun: pgRow.rootTaskRun ? foldVersion(pgRow.rootTaskRun) : null,
childRuns: pgRow.childRuns.map((child) => foldVersion(child)),
isBuffered: false,
};
}
// Postgres miss → fall back to the mollifier buffer. When the gate
// diverted a trigger, the run lives in Redis until the drainer replays
@@ -499,7 +547,10 @@ async function resolveSchedule(run: CommonRelatedRun) {
};
}
async function createCommonRunStructure(run: CommonRelatedRun, apiVersion: API_VERSIONS) {
async function createCommonRunStructure(
run: CommonRelatedRunWithVersion,
apiVersion: API_VERSIONS
) {
const metadata = await parsePacketAsJson({
data: run.metadata ?? undefined,
dataType: run.metadataType,
@@ -666,6 +717,8 @@ export function synthesiseFoundRunFromBuffer(buffered: SyntheticRun): FoundRun {
// field in the API response instead of silently dropping it until the
// drainer materialises.
scheduleId: buffered.scheduleId ?? null,
// commonRunSelect now carries the scalar id; a buffered run has no locked worker yet.
lockedToVersionId: null,
lockedToVersion: buffered.lockedToVersion ? { version: buffered.lockedToVersion } : null,
resumeParentOnCompletion: buffered.resumeParentOnCompletion,
// Reconstruct the batch from the snapshot's internal id so a buffered
@@ -4,6 +4,7 @@ import assertNever from "assert-never";
import { z } from "zod";
import type { API_VERSIONS } from "~/api/versions";
import { RunStatusUnspecifiedApiVersion } from "~/api/versions";
import type { PrismaClientOrTransaction } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { logger } from "~/services/logger.server";
import { CoercedDate } from "~/utils/zod";
@@ -12,6 +13,15 @@ import { ApiRetrieveRunPresenter } from "./ApiRetrieveRunPresenter.server";
import { NextRunListPresenter, type RunListOptions } from "./NextRunListPresenter.server";
import { BasePresenter } from "./basePresenter.server";
// Forwarded verbatim into `NextRunListPresenter` for the routed run-ops (TaskRun) reads. When
// omitted, both clients default to the inherited `_replica` => passthrough single-DB. The
// control-plane `runtimeEnvironment.findMany` env-scoping lookup is never routed.
type ApiRunListPresenterReadThroughDeps = {
newClient?: PrismaClientOrTransaction;
legacyReplica?: PrismaClientOrTransaction;
splitEnabled?: boolean;
};
export const ApiRunListSearchParams = z.object({
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
"page[after]": z.string().optional(),
@@ -151,6 +161,14 @@ export const ApiRunListSearchParams = z.object({
type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
export class ApiRunListPresenter extends BasePresenter {
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readThroughDeps?: ApiRunListPresenterReadThroughDeps
) {
super(prismaClient, replicaClient);
}
public async call(
project: Pick<Project, "id">,
searchParams: ApiRunListSearchParams,
@@ -274,7 +292,7 @@ export class ApiRunListPresenter extends BasePresenter {
organizationId,
"standard"
);
const presenter = new NextRunListPresenter(this._replica, clickhouse);
const presenter = new NextRunListPresenter(this._replica, clickhouse, this.readThroughDeps);
logger.debug("Calling RunListPresenter", { options });
@@ -1,31 +1,57 @@
import type { TaskRunExecutionResult } from "@trigger.dev/core/v3";
import type { PrismaClientOrTransaction, PrismaReplicaClient } from "~/db.server";
import { executionResultForTaskRun } from "~/models/taskRun.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { runStore } from "~/v3/runStore.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
import { BasePresenter } from "./basePresenter.server";
type ApiRunResultReadThroughDeps = {
splitEnabled?: boolean;
newClient?: PrismaReplicaClient;
// LEGACY RUN-OPS READ REPLICA ONLY (never a writer/primary); defaults to this._replica.
legacyReplica?: PrismaReplicaClient;
isPastRetention?: (runId: string) => boolean;
};
export class ApiRunResultPresenter extends BasePresenter {
constructor(
prisma?: PrismaClientOrTransaction,
replica?: PrismaClientOrTransaction,
private readonly _readThrough?: ApiRunResultReadThroughDeps
) {
super(prisma, replica);
}
public async call(
friendlyId: string,
env: AuthenticatedEnvironment
): Promise<TaskRunExecutionResult | undefined> {
return this.traceWithEnv("call", env, async (span) => {
const taskRun = await runStore.findRun(
{
friendlyId,
runtimeEnvironmentId: env.id,
const findRun = (client: PrismaReplicaClient) =>
client.taskRun.findFirst({
where: { friendlyId, runtimeEnvironmentId: env.id },
include: { attempts: { orderBy: { createdAt: "desc" } } },
});
// Single-run result poll routed through run-ops read-through. Split on: primary store first,
// then the secondary read replica for runs that miss on new; past-retention ids return
// undefined -> the route's normal 404. Split off (single-DB / self-host): readThroughRun does
// one plain findFirst against the single client (passthrough).
const result = await readThroughRun({
runId: friendlyId,
environmentId: env.id,
readNew: findRun,
readLegacy: findRun,
deps: {
splitEnabled: this._readThrough?.splitEnabled,
newClient: this._readThrough?.newClient ?? (this._prisma as PrismaReplicaClient),
legacyReplica: this._readThrough?.legacyReplica ?? (this._replica as PrismaReplicaClient),
isPastRetention: this._readThrough?.isPastRetention,
},
{
include: {
attempts: {
orderBy: {
createdAt: "desc",
},
},
},
},
this._prisma
);
});
const taskRun =
result.source === "new" || result.source === "legacy-replica" ? result.value : undefined;
if (!taskRun) {
return undefined;
@@ -1,6 +1,7 @@
import { type RuntimeEnvironmentType, WaitpointTokenStatus } from "@trigger.dev/core/v3";
import { type RunEngineVersion } from "@trigger.dev/database";
import { z } from "zod";
import { type PrismaClientOrTransaction } from "~/db.server";
import { CoercedDate } from "~/utils/zod";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { BasePresenter } from "./basePresenter.server";
@@ -60,6 +61,18 @@ export const ApiWaitpointListSearchParams = z.object({
type ApiWaitpointListSearchParams = z.infer<typeof ApiWaitpointListSearchParams>;
export class ApiWaitpointListPresenter extends BasePresenter {
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readRoute?: {
runOpsNew?: PrismaClientOrTransaction;
runOpsLegacyReplica?: PrismaClientOrTransaction;
splitEnabled?: boolean;
}
) {
super(prismaClient, replicaClient);
}
public async call(
environment: {
id: string;
@@ -115,7 +128,7 @@ export class ApiWaitpointListPresenter extends BasePresenter {
options.to = searchParams["filter[createdAt][to]"].getTime();
}
const presenter = new WaitpointListPresenter();
const presenter = new WaitpointListPresenter(undefined, undefined, this.readRoute);
const result = await presenter.call(options);
if (!result.success) {
@@ -4,8 +4,28 @@ import { ServiceValidationError } from "~/v3/services/baseService.server";
import { BasePresenter } from "./basePresenter.server";
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
import type { PrismaClientOrTransaction, PrismaReplicaClient } from "~/db.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
// When omitted, clients default to the inherited _replica handle => passthrough reads the
// replica exactly as today. isPastRetention is injectable for tests. Typed PrismaReplicaClient
// to match readThroughRun's readNew/readLegacy + deps.
type ApiWaitpointPresenterReadThroughDeps = {
newClient?: PrismaReplicaClient;
legacyReplica?: PrismaReplicaClient;
splitEnabled?: boolean;
isPastRetention?: (id: string) => boolean;
};
export class ApiWaitpointPresenter extends BasePresenter {
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readThroughDeps?: ApiWaitpointPresenterReadThroughDeps
) {
super(prismaClient, replicaClient);
}
public async call(
environment: {
id: string;
@@ -19,36 +39,56 @@ export class ApiWaitpointPresenter extends BasePresenter {
waitpointId: string
) {
return this.trace("call", async (span) => {
const waitpoint = await this._replica.waitpoint.findFirst({
where: {
id: waitpointId,
environmentId: environment.id,
},
select: {
id: true,
friendlyId: true,
type: true,
status: true,
idempotencyKey: true,
userProvidedIdempotencyKey: true,
idempotencyKeyExpiresAt: true,
inactiveIdempotencyKey: true,
output: true,
outputType: true,
outputIsError: true,
completedAfter: true,
completedAt: true,
createdAt: true,
connectedRuns: {
select: {
friendlyId: true,
},
take: 5,
// Public waitpoint retrieve. Split on: new run-ops client first, then the LEGACY
// RUN-OPS READ REPLICA ONLY on a new-probe miss — never the legacy primary.
// Split off (single-DB / self-host): one plain waitpoint.findFirst against the replica
// (passthrough). The waitpointId is the residency-classifiable KSUID id (the route
// pre-decodes the friendlyId via WaitpointId.toId).
const hydrate = (client: PrismaReplicaClient) =>
client.waitpoint.findFirst({
where: {
id: waitpointId,
environmentId: environment.id,
},
tags: true,
select: {
id: true,
friendlyId: true,
type: true,
status: true,
idempotencyKey: true,
userProvidedIdempotencyKey: true,
idempotencyKeyExpiresAt: true,
inactiveIdempotencyKey: true,
output: true,
outputType: true,
outputIsError: true,
completedAfter: true,
completedAt: true,
createdAt: true,
tags: true,
},
});
const result = await readThroughRun({
runId: waitpointId,
environmentId: environment.id,
readNew: (client) => hydrate(client),
readLegacy: (replica) => hydrate(replica),
deps: {
splitEnabled: this.readThroughDeps?.splitEnabled,
// Default both clients to the inherited _replica handle (declared
// PrismaClientOrTransaction but $replica at runtime) so passthrough reads the replica
// as today; split mode injects a distinct newClient.
newClient: this.readThroughDeps?.newClient ?? (this._replica as PrismaReplicaClient),
legacyReplica:
this.readThroughDeps?.legacyReplica ?? (this._replica as PrismaReplicaClient),
isPastRetention: this.readThroughDeps?.isPastRetention,
},
});
const waitpoint =
result.source === "new" || result.source === "legacy-replica" ? result.value : null;
if (!waitpoint) {
logger.error(`WaitpointPresenter: Waitpoint not found`, {
id: waitpointId,
@@ -1,6 +1,6 @@
import { type BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
import { type BatchTaskRunStatus } from "@trigger.dev/database";
import parse from "parse-duration";
import { sqlDatabaseSchema } from "~/db.server";
import { type PrismaClientOrTransaction } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { BasePresenter } from "./basePresenter.server";
import { type Direction } from "~/components/ListPagination";
@@ -28,7 +28,113 @@ export type BatchList = Awaited<ReturnType<BatchListPresenter["call"]>>;
export type BatchListItem = BatchList["batches"][0];
export type BatchListAppliedFilters = BatchList["filters"];
// The row shape of the raw BatchTaskRun keyset scan. Extracted to a named type so the
// store-selected scan closure and the keyset merge in `#scanBatchTaskRun` can reference it.
type BatchRow = {
id: string;
friendlyId: string;
runtimeEnvironmentId: string;
status: BatchTaskRunStatus;
createdAt: Date;
updatedAt: Date;
completedAt: Date | null;
runCount: number;
batchVersion: string;
};
export class BatchListPresenter extends BasePresenter {
// Optional run-ops read-routing. Omitted (single-DB / self-host) => everything
// reads from `_replica` exactly as today (passthrough). Field names are local to
// this presenter; only the read-routing convention (optional handles, default-to-_replica,
// boot-constant splitEnabled) is mirrored, not the literal RunsRepositoryOptions names.
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readRoute?: {
runOpsNew?: PrismaClientOrTransaction; // new run-ops client
runOpsLegacyReplica?: PrismaClientOrTransaction; // legacy run-ops READ REPLICA only — never the legacy primary
controlPlaneReplica?: PrismaClientOrTransaction; // control-plane DB (for project)
splitEnabled?: boolean; // resolved boot constant
}
) {
super(prismaClient, replicaClient);
}
// Control-plane READ handle for the `project` lookup. In single-DB / when omitted this is
// `_replica` ⇒ unchanged.
get #controlPlaneReplica(): PrismaClientOrTransaction {
return this.readRoute?.controlPlaneReplica ?? this._replica;
}
// Run-ops reads for the Batches dashboard. Split on: new run-ops DB first; the LEGACY
// RUN-OPS READ REPLICA ONLY for the older not-yet-migrated remainder/empty-state — never the
// legacy primary. Split off (single-DB / self-host): one plain `_replica` read (passthrough).
// `project` is resolved on the control-plane DB; the environment↔batch join is in-memory (no
// cross-seam SQL join).
async #scanBatchTaskRun(
pageSize: number,
direction: Direction,
scan: (client: PrismaClientOrTransaction) => Promise<BatchRow[]>
): Promise<BatchRow[]> {
if (!this.readRoute?.splitEnabled) {
return scan(this._replica);
}
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica);
// New DB filled the page — skip the legacy read entirely; older rows fall on a later page.
if (newRows.length >= pageSize + 1) {
return newRows;
}
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? this._replica);
// De-dupe by id (new wins), re-sort under the page's keyset order, re-apply the over-fetch
// LIMIT — reproduces the pageSize+1 window a single union scan would return.
const byId = new Map<string, BatchRow>();
for (const row of newRows) {
byId.set(row.id, row);
}
for (const row of legacyRows) {
if (!byId.has(row.id)) {
byId.set(row.id, row);
}
}
// codepoint comparator (NEVER localeCompare): BatchTaskRun.id is ASCII (cuid or ksuid).
const sign = direction === "forward" ? 1 : -1; // forward => DESC; backward => ASC
return Array.from(byId.values())
.sort((a, b) => (a.id < b.id ? sign : a.id > b.id ? -sign : 0))
.slice(0, pageSize + 1);
}
// Empty-state probe. Split on: probe the new run-ops DB first, then the legacy READ REPLICA only
// (never the legacy primary). Split off (single-DB / self-host): one plain `_replica` probe.
async #probeAnyBatch(environmentId: string): Promise<boolean> {
// Passthrough: probe the SAME client the scan uses (_replica), or the empty-state hint can
// disagree with the page when a run-ops DB is configured but read-split is off.
if (!this.readRoute?.splitEnabled) {
const onReplica = await this._replica.batchTaskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
});
return Boolean(onReplica);
}
const onNew = await (this.readRoute.runOpsNew ?? this._replica).batchTaskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
});
if (onNew) {
return true;
}
const onLegacy = await (
this.readRoute.runOpsLegacyReplica ?? this._replica
).batchTaskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
});
return Boolean(onLegacy);
}
public async call({
userId,
projectId,
@@ -53,8 +159,7 @@ export class BatchListPresenter extends BasePresenter {
const hasFilters = hasStatusFilters || friendlyId !== undefined || !time.isDefault;
// Find the project scoped to the organization
const project = await this._replica.project.findFirstOrThrow({
const project = await this.#controlPlaneReplica.project.findFirstOrThrow({
select: {
id: true,
environments: {
@@ -83,66 +188,53 @@ export class BatchListPresenter extends BasePresenter {
const periodMs = time.period ? parse(time.period) : undefined;
//get the batches
const batches = await this._replica.$queryRaw<
{
id: string;
friendlyId: string;
runtimeEnvironmentId: string;
status: BatchTaskRunStatus;
createdAt: Date;
updatedAt: Date;
completedAt: Date | null;
runCount: bigint;
batchVersion: string;
}[]
>`
SELECT
b.id,
b."friendlyId",
b."runtimeEnvironmentId",
b.status,
b."createdAt",
b."updatedAt",
b."completedAt",
b."runCount",
b."batchVersion"
FROM
${sqlDatabaseSchema}."BatchTaskRun" b
WHERE
-- environment
b."runtimeEnvironmentId" = ${environmentId}
-- cursor
${
cursor
? direction === "forward"
? Prisma.sql`AND b.id < ${cursor}`
: Prisma.sql`AND b.id > ${cursor}`
: Prisma.empty
let createdAtGte: Date | undefined;
if (periodMs != null) {
createdAtGte = new Date(Date.now() - periodMs);
}
-- filters
${friendlyId ? Prisma.sql`AND b."friendlyId" = ${friendlyId}` : Prisma.empty}
${
statuses && statuses.length > 0
? Prisma.sql`AND b.status = ANY(ARRAY[${Prisma.join(
statuses
)}]::"BatchTaskRunStatus"[]) AND b."batchVersion" <> 'v1'`
: Prisma.empty
if (time.from !== undefined) {
createdAtGte =
createdAtGte === undefined
? time.from
: time.from > createdAtGte
? time.from
: createdAtGte;
}
${
periodMs
? Prisma.sql`AND b."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
: Prisma.empty
}
${
time.from
? Prisma.sql`AND b."createdAt" >= ${time.from.toISOString()}::timestamp`
: Prisma.empty
}
${time.to ? Prisma.sql`AND b."createdAt" <= ${time.to.toISOString()}::timestamp` : Prisma.empty}
ORDER BY
${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`}
LIMIT ${pageSize + 1}`;
const createdAtLte: Date | undefined = time.to;
const batches = await this.#scanBatchTaskRun(pageSize, direction, (client) =>
client.batchTaskRun.findMany({
where: {
runtimeEnvironmentId: environmentId,
...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}),
...(friendlyId ? { friendlyId } : {}),
...(statuses && statuses.length > 0
? { status: { in: statuses }, batchVersion: { not: "v1" } }
: {}),
...(createdAtGte !== undefined || createdAtLte !== undefined
? {
createdAt: {
...(createdAtGte !== undefined ? { gte: createdAtGte } : {}),
...(createdAtLte !== undefined ? { lte: createdAtLte } : {}),
},
}
: {}),
},
orderBy: { id: direction === "forward" ? "desc" : "asc" },
take: pageSize + 1,
select: {
id: true,
friendlyId: true,
runtimeEnvironmentId: true,
status: true,
createdAt: true,
updatedAt: true,
completedAt: true,
runCount: true,
batchVersion: true,
},
})
);
const hasMore = batches.length > pageSize;
@@ -174,15 +266,7 @@ WHERE
let hasAnyBatches = batchesToReturn.length > 0;
if (!hasAnyBatches) {
const firstBatch = await this._replica.batchTaskRun.findFirst({
where: {
runtimeEnvironmentId: environmentId,
},
});
if (firstBatch) {
hasAnyBatches = true;
}
hasAnyBatches = await this.#probeAnyBatch(environmentId);
}
return {
@@ -1,6 +1,8 @@
import { type BatchTaskRunStatus } from "@trigger.dev/database";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
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 = {
@@ -9,63 +11,92 @@ type BatchPresenterOptions = {
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<ReturnType<BatchPresenter["call"]>>;
export class BatchPresenter extends BasePresenter {
constructor(
_prisma?: PrismaClientOrTransaction,
_replica?: PrismaClientOrTransaction,
private readonly deps: BatchPresenterDeps = {}
) {
super(_prisma, _replica);
}
public async call({ environmentId, batchId, userId }: BatchPresenterOptions) {
const batch = await this._replica.batchTaskRun.findFirst({
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,
runtimeEnvironment: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
errors: {
select: {
id: true,
index: true,
taskIdentifier: true,
error: true,
errorCode: true,
createdAt: true,
},
orderBy: {
index: "asc",
},
},
},
where: {
runtimeEnvironmentId: environmentId,
friendlyId: batchId,
// 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<BatchRow | null> =>
client.batchTaskRun.findFirst({ select: BATCH_SELECT, where });
const readThrough = this.deps.readThrough ?? readThroughRun;
const batchResult = await readThrough<BatchRow>({
// 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 ksuid-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");
}
@@ -86,6 +117,9 @@ export class BatchPresenter extends BasePresenter {
}
}
// 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,
@@ -107,7 +141,7 @@ export class BatchPresenter extends BasePresenter {
successfulRunCount: liveSuccessCount,
failedRunCount: liveFailureCount,
idempotencyKey: batch.idempotencyKey,
environment: displayableEnvironment(batch.runtimeEnvironment, userId),
environment: await resolveEnv(environmentId, userId),
errors: batch.errors.map((error) => ({
id: error.id,
index: error.index,
@@ -13,7 +13,6 @@ import { getTaskIdentifiers } from "~/models/task.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { regionForDisplay } from "~/runEngine/concerns/workerQueueSplit.server";
import { machinePresetFromRun } from "~/v3/machinePresets.server";
import { runStore } from "~/v3/runStore.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
@@ -54,9 +53,51 @@ export type NextRunListAppliedFilters = NextRunList["filters"];
export class NextRunListPresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
private readonly clickhouse: ClickHouse,
private readonly readThroughDeps?: {
// The new run-ops client + the legacy run-ops read replica (never the legacy writer).
// Omitted => single-DB / self-host: both default to `replica` (passthrough).
newClient?: PrismaClientOrTransaction;
legacyReplica?: PrismaClientOrTransaction;
// Resolved boot constant from isSplitEnabled(). When false/absent:
// list hydrate runs passthrough and the empty-state probe is one plain findFirst.
splitEnabled?: boolean;
}
) {}
// Existence probe for the empty-state. run-ops (TaskRun) read.
// Split off / single-DB: one plain findFirst on `replica` (passthrough).
// Split on: true if a row exists in the NEW run-ops DB OR the LEGACY run-ops
// read replica only (never the legacy writer). New first to avoid touching
// legacy whenever the new DB already answers.
async #anyRunExistsInEnv(environmentId: string): Promise<boolean> {
const splitEnabled = this.readThroughDeps?.splitEnabled ?? false;
if (!splitEnabled) {
const firstRun = await this.replica.taskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
select: { id: true },
});
return firstRun !== null;
}
const newClient = this.readThroughDeps?.newClient ?? this.replica;
const newRun = await newClient.taskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
select: { id: true },
});
if (newRun) {
return true;
}
const legacyReplica = this.readThroughDeps?.legacyReplica ?? this.replica;
const legacyRun = await legacyReplica.taskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
select: { id: true },
});
return legacyRun !== null;
}
public async call(
organizationId: string,
environmentId: string,
@@ -113,10 +154,8 @@ export class NextRunListPresenter {
rootOnly === true ||
!time.isDefault;
//get all possible tasks
const possibleTasksAsync = getTaskIdentifiers(environmentId);
//get possible bulk actions
const bulkActionsAsync = this.replica.bulkActionGroup.findMany({
select: {
friendlyId: true,
@@ -168,6 +207,13 @@ export class NextRunListPresenter {
const runsRepository = new RunsRepository({
clickhouse: this.clickhouse,
prisma: this.replica as PrismaClient,
readThrough: this.readThroughDeps
? {
newClient: this.readThroughDeps.newClient ?? this.replica,
legacyReplica: this.readThroughDeps.legacyReplica ?? this.replica,
splitEnabled: this.readThroughDeps.splitEnabled ?? false,
}
: undefined,
});
function clampToNow(date: Date): Date {
@@ -207,16 +253,7 @@ export class NextRunListPresenter {
let hasAnyRuns = runs.length > 0;
if (!hasAnyRuns) {
const firstRun = await runStore.findRun(
{
runtimeEnvironmentId: environmentId,
},
this.replica
);
if (firstRun) {
hasAnyRuns = true;
}
hasAnyRuns = await this.#anyRunExistsInEnv(environmentId);
}
return {
@@ -10,6 +10,7 @@ import { isFinalRunStatus } from "~/v3/taskStatus";
import { env } from "~/env.server";
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { runStore } from "~/v3/runStore.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
export type Run = Result["run"];
@@ -64,22 +65,22 @@ export class RunPresenter {
// buffer view. `findFirstOrThrow` would log a `PrismaClient error`
// every tick of the page poll, masking real DB issues with synthetic
// not-found noise.
//
// No explicit client arg: the store reads off its own replica and routes by
// residency once a RoutingRunStore is injected. Pinning this.#prismaClient
// would override that routing. (The user.findFirst admin check below stays on
// the control-plane client.)
// Run-ops read keyed by friendlyId only — routes to the owning DB by residency.
// The project-scope + membership auth is a control-plane concern resolved
// separately below; joining project/organization here is a cross-DB join that
// returns nothing once the run lives in the run-ops DB.
const run = await runStore.findRun(
{
friendlyId: runFriendlyId,
project: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
},
{
select: {
projectId: true,
id: true,
createdAt: true,
taskEventStore: true,
@@ -108,35 +109,42 @@ export class RunPresenter {
createdAt: true,
},
},
runtimeEnvironment: {
select: {
id: true,
type: true,
slug: true,
organizationId: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
runtimeEnvironmentId: true,
},
},
this.#prismaClient
}
);
if (!run) {
throw new RunNotInPgError(runFriendlyId);
}
if (environmentSlug !== run.runtimeEnvironment.slug) {
// Project-scope + membership auth is control-plane only — verify the run's
// project matches the requested slug and the user is a member, keyed by the
// run's projectId. A miss is treated as not-found (mirrors the old scoped where).
const authorizedProject = await this.#prismaClient.project.findFirst({
where: {
id: run.projectId,
slug: projectSlug,
organization: { members: { some: { userId } } },
},
select: { id: true },
});
if (!authorizedProject) {
throw new RunNotInPgError(runFriendlyId);
}
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(
run.runtimeEnvironmentId
);
if (!environment) {
// An unresolvable control-plane env means the run can't be presented from PG;
// mirror the not-found path the route already handles (mollifier buffer fallback).
throw new RunNotInPgError(runFriendlyId);
}
if (environmentSlug !== environment.slug) {
throw new RunEnvironmentMismatchError(
`Run ${runFriendlyId} is not in environment ${environmentSlug}`
);
@@ -158,12 +166,12 @@ export class RunPresenter {
rootTaskRun: run.rootTaskRun,
parentTaskRun: run.parentTaskRun,
environment: {
id: run.runtimeEnvironment.id,
organizationId: run.runtimeEnvironment.organizationId,
type: run.runtimeEnvironment.type,
slug: run.runtimeEnvironment.slug,
userId: run.runtimeEnvironment.orgMember?.user.id,
userName: getUsername(run.runtimeEnvironment.orgMember?.user),
id: environment.id,
organizationId: environment.organizationId,
type: environment.type,
slug: environment.slug,
userId: environment.orgMember?.user?.id,
userName: getUsername(environment.orgMember?.user),
},
};
@@ -177,7 +185,7 @@ export class RunPresenter {
const repository = await getEventRepositoryForStore(
run.taskEventStore,
run.runtimeEnvironment.organizationId
environment.organizationId
);
const traceTimeBounds = {
@@ -189,7 +197,7 @@ export class RunPresenter {
// span fell past the row cap (large traces ordered by start_time ASC).
let traceSummary = await repository.getTraceSummary(
getTaskEventStoreTableForRun(run),
run.runtimeEnvironment.id,
environment.id,
run.traceId,
traceTimeBounds.startCreatedAt,
traceTimeBounds.endCreatedAt,
@@ -209,7 +217,7 @@ export class RunPresenter {
const subtreeSummary = await repository.getTraceSubtreeSummary(
getTaskEventStoreTableForRun(run),
run.runtimeEnvironment.id,
environment.id,
run.traceId,
run.spanId,
traceTimeBounds.startCreatedAt,
@@ -260,6 +268,8 @@ export class RunPresenter {
};
}
// Control-plane read (User table) — stays on the control-plane client, NOT
// routed through the run-ops store (user resolved CP-side, run run-ops-side).
const user = await this.#prismaClient.user.findFirst({
where: {
id: userId,
@@ -38,34 +38,42 @@ export class RunStreamPresenter {
// Scope the lookup to organizations the requesting user is a member
// of, matching RunPresenter's run lookup. Unauthorized and missing
// runs are indistinguishable (both 404).
// Run-ops read by friendlyId only (routes to the owning DB); the project/org
// membership auth is a control-plane concern resolved separately below — joining
// it here is a cross-DB join that returns nothing once the run lives in run-ops.
const run = await runStore.findRun(
{
friendlyId: runFriendlyId,
project: {
organization: {
members: {
some: {
userId,
},
},
},
},
},
{
select: {
traceId: true,
projectId: true,
},
},
prismaClient
}
);
// Authorize on the control-plane DB, keyed by the run's project. A non-member
// (or unresolvable project) is treated as no-access: traceId stays null.
let authorized = false;
if (run) {
const authorizedProject = await prismaClient.project.findFirst({
where: {
id: run.projectId,
organization: { members: { some: { userId } } },
},
select: { id: true },
});
authorized = authorizedProject !== null;
}
// Fall back to the mollifier buffer when the run isn't in PG yet.
// The buffered run has no execution events to stream, but we still
// attach a trace-pubsub subscription using the snapshot's traceId
// so that the moment the drainer materialises the row and execution
// begins, those events flow to this open SSE connection. Closing
// with 404 would force the dashboard to keep retrying.
let traceId: string | null = run?.traceId ?? null;
let traceId: string | null = run && authorized ? run.traceId : null;
if (!traceId) {
const buffer = getMollifierBuffer();
if (buffer) {
@@ -107,7 +115,6 @@ export class RunStreamPresenter {
traceId: resolvedRun.traceId,
});
// Subscribe to trace updates
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(
resolvedRun.traceId
);
@@ -142,10 +149,8 @@ export class RunStreamPresenter {
return {
initStream: ({ send }) => {
// Create throttled send function
throttledSend({ send, event: "message", data: new Date().toISOString() });
// Set up message listener for pub/sub events
messageListener = (event: string) => {
throttledSend({ send, event: "message", data: event });
};
@@ -155,7 +160,6 @@ export class RunStreamPresenter {
},
iterator: ({ send }) => {
// Send ping to keep connection alive
try {
// Send an actual message so the client refreshes
throttledSend({ send, event: "message", data: new Date().toISOString() });
@@ -171,13 +175,11 @@ export class RunStreamPresenter {
traceId: resolvedRun.traceId,
});
// Remove message listener
if (messageListener) {
eventEmitter.removeListener("message", messageListener);
}
eventEmitter.removeAllListeners();
// Unsubscribe from Redis pub/sub
unsubscribe()
.then(() => {
logger.info("RunStreamPresenter.cleanup.unsubscribe succeeded", {
@@ -203,7 +205,6 @@ export class RunStreamPresenter {
}
}
// Export a singleton loader for the route to use
export const runStreamLoader = singleton("runStreamLoader", () => {
const presenter = new RunStreamPresenter();
return presenter.createLoader();
@@ -36,6 +36,11 @@ import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/tas
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
import { BasePresenter } from "./basePresenter.server";
import { WaitpointPresenter } from "./WaitpointPresenter.server";
import {
controlPlaneResolver,
type ResolvedRunLockedWorker,
} from "~/v3/runOpsMigration/controlPlaneResolver.server";
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
export type PromptSpanData = {
slug: string;
@@ -76,16 +81,10 @@ function extractPromptSpanData(properties: Record<string, unknown>): PromptSpanD
};
}
// SpanRun is grounded in the PG-path `getRun` method rather than
// inferred from `call`'s return type. The buffered branch of `call`
// routes through `buildSyntheticSpanRun`, and that helper is annotated
// `Promise<SpanRun>` — if SpanRun were derived from `call` it would
// close a loop TS no longer tolerates ("Type alias 'Result' circularly
// references itself"). `getRun` is the canonical source for the shape
// (the synthetic helper just rebuilds the same shape from a buffer
// snapshot), and it doesn't recurse, so grounding here breaks the
// cycle while keeping Span available off `call` (Span's path through
// `#getSpan` has no synthetic indirection).
// Grounded in `getRun` (the canonical shape source), not inferred from `call`:
// `call`'s buffered branch returns `buildSyntheticSpanRun` which is annotated
// `Promise<SpanRun>`, so deriving SpanRun from `call` would be a circular type
// reference TS rejects. `getRun` doesn't recurse, breaking the cycle.
export type SpanRun = NonNullable<
Awaited<ReturnType<InstanceType<typeof SpanPresenter>["getRun"]>>
>;
@@ -94,6 +93,10 @@ export type Span = NonNullable<NonNullable<Result>["span"]>;
type FindRunResult = NonNullable<
Awaited<ReturnType<InstanceType<typeof SpanPresenter>["findRun"]>>
>;
// Run-ops TaskRun reads (parent run in `call`, hydrate in `findRun`, children in
// `#getSpan`) go through the `runStore` seam; split routing is the RoutingRunStore's
// job below it. Control-plane reads stay on `this._replica`/`this._prisma`.
export class SpanPresenter extends BasePresenter {
public async call({
userId,
@@ -251,18 +254,31 @@ export class SpanPresenter extends BasePresenter {
return;
}
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(
run.runtimeEnvironmentId
);
if (!environment) {
return undefined;
}
const lockedWorker = await controlPlaneResolver.resolveRunLockedWorker({
lockedById: run.lockedById,
lockedToVersionId: run.lockedToVersionId,
});
const isFinished = isFinalRunStatus(run.status);
const output = !isFinished
? undefined
: run.outputType === "application/store"
? `/resources/packets/${run.runtimeEnvironment.id}/${run.output}`
? `/resources/packets/${environment.id}/${run.output}`
: typeof run.output !== "undefined" && run.output !== null
? await prettyPrintPacket(run.output, run.outputType ?? undefined)
: undefined;
const payload =
run.payloadType === "application/store"
? `/resources/packets/${run.runtimeEnvironment.id}/${run.payload}`
? `/resources/packets/${environment.id}/${run.payload}`
: typeof run.payload !== "undefined" && run.payload !== null
? await prettyPrintPacket(run.payload, run.payloadType ?? undefined)
: undefined;
@@ -289,7 +305,12 @@ export class SpanPresenter extends BasePresenter {
const machine = run.machinePreset ? machinePresetFromRun(run) : undefined;
const context = await this.#getTaskRunContext({ run, machine: machine ?? undefined });
const context = await this.#getTaskRunContext({
run,
machine: machine ?? undefined,
environment,
lockedWorker,
});
const externalTraceId = this.#getExternalTraceId(run.traceContext);
@@ -299,7 +320,7 @@ export class SpanPresenter extends BasePresenter {
let region: { name: string; location: string | null } | null = null;
if (run.runtimeEnvironment.type !== "DEVELOPMENT" && run.engine !== "V1") {
if (environment.type !== "DEVELOPMENT" && run.engine !== "V1") {
const workerGroup = await this._replica.workerInstanceGroup.findFirst({
select: {
name: true,
@@ -319,10 +340,9 @@ export class SpanPresenter extends BasePresenter {
: null;
}
// Only AGENT-tagged runs (chat.agent and friends) can be session-bound,
// so skip the SessionRun lookup for the much larger set of standard runs.
// Lookup is by the unique `runId` index, but the cheapest query is the
// one we don't run.
// Only AGENT-tagged runs can be session-bound, so skip the SessionRun lookup
// for the much larger set of standard runs — the cheapest query is the one we
// don't run.
const sessionRun = isAgentRun
? await this._replica.sessionRun.findFirst({
where: { runId: run.id },
@@ -376,13 +396,13 @@ export class SpanPresenter extends BasePresenter {
logsDeletedAt: run.logsDeletedAt,
ttl: run.ttl,
taskIdentifier: run.taskIdentifier,
version: run.lockedToVersion?.version,
sdkVersion: run.lockedToVersion?.sdkVersion,
runtime: run.lockedToVersion?.runtime,
runtimeVersion: run.lockedToVersion?.runtimeVersion,
version: lockedWorker?.lockedToVersion?.version,
sdkVersion: lockedWorker?.lockedToVersion?.sdkVersion,
runtime: lockedWorker?.lockedToVersion?.runtime,
runtimeVersion: lockedWorker?.lockedToVersion?.runtimeVersion,
isTest: run.isTest,
replayedFromTaskRunFriendlyId: run.replayedFromTaskRunFriendlyId,
environmentId: run.runtimeEnvironment.id,
environmentId: environment.id,
idempotencyKey: getUserProvidedIdempotencyKey(run),
idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt,
idempotencyKeyScope: extractIdempotencyKeyScope(run),
@@ -511,6 +531,9 @@ export class SpanPresenter extends BasePresenter {
{
select: {
id: true,
runtimeEnvironmentId: true,
lockedById: true,
lockedToVersionId: true,
spanId: true,
traceId: true,
traceContext: true,
@@ -523,14 +546,6 @@ export class SpanPresenter extends BasePresenter {
taskEventStore: true,
runTags: true,
machinePreset: true,
lockedToVersion: {
select: {
version: true,
sdkVersion: true,
runtime: true,
runtimeVersion: true,
},
},
engine: true,
workerQueue: true,
region: true,
@@ -567,26 +582,12 @@ export class SpanPresenter extends BasePresenter {
baseCostInCents: true,
costInCents: true,
usageDurationMs: true,
//env
runtimeEnvironment: {
select: { id: true, slug: true, type: true },
},
payload: true,
payloadType: true,
metadata: true,
metadataType: true,
annotations: true,
maxAttempts: true,
project: {
include: {
organization: true,
},
},
lockedBy: {
select: {
filePath: true,
},
},
//relationships
rootTaskRun: {
select: {
@@ -721,7 +722,7 @@ export class SpanPresenter extends BasePresenter {
return { ...data, entity: null };
}
const presenter = new WaitpointPresenter();
const presenter = new WaitpointPresenter(undefined, undefined, {});
const waitpoint = await presenter.call({
friendlyId: span.entity.id,
environmentId,
@@ -968,9 +969,19 @@ export class SpanPresenter extends BasePresenter {
};
}
async #getTaskRunContext({ run, machine }: { run: FindRunResult; machine?: MachinePreset }) {
async #getTaskRunContext({
run,
machine,
environment,
lockedWorker,
}: {
run: FindRunResult;
machine?: MachinePreset;
environment: AuthenticatedEnvironment;
lockedWorker: ResolvedRunLockedWorker | null;
}) {
if (run.engine === "V1") {
return this.#getV3TaskRunContext({ run, machine });
return this.#getV3TaskRunContext({ run, machine, environment, lockedWorker });
} else {
return this.#getV4TaskRunContext({ run });
}
@@ -979,9 +990,13 @@ export class SpanPresenter extends BasePresenter {
async #getV3TaskRunContext({
run,
machine,
environment,
lockedWorker,
}: {
run: FindRunResult;
machine?: MachinePreset;
environment: AuthenticatedEnvironment;
lockedWorker: ResolvedRunLockedWorker | null;
}): Promise<V3TaskRunContext> {
const attempt = run.attempts[0];
@@ -1001,7 +1016,7 @@ export class SpanPresenter extends BasePresenter {
},
task: {
id: run.taskIdentifier,
filePath: run.lockedBy?.filePath ?? "",
filePath: lockedWorker?.lockedBy?.filePath ?? "",
},
run: {
id: run.friendlyId,
@@ -1015,7 +1030,7 @@ export class SpanPresenter extends BasePresenter {
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
maxAttempts: run.maxAttempts ?? undefined,
version: run.lockedToVersion?.version,
version: lockedWorker?.lockedToVersion?.version,
maxDuration: run.maxDurationInSeconds ?? undefined,
},
queue: {
@@ -1023,20 +1038,20 @@ export class SpanPresenter extends BasePresenter {
id: run.queue,
},
environment: {
id: run.runtimeEnvironment.id,
slug: run.runtimeEnvironment.slug,
type: run.runtimeEnvironment.type,
id: environment.id,
slug: environment.slug,
type: environment.type,
},
organization: {
id: run.project.organization.id,
slug: run.project.organization.slug,
name: run.project.organization.title,
id: environment.organization.id,
slug: environment.organization.slug,
name: environment.organization.title,
},
project: {
id: run.project.id,
ref: run.project.externalRef,
slug: run.project.slug,
name: run.project.name,
id: environment.project.id,
ref: environment.project.externalRef,
slug: environment.project.slug,
name: environment.project.name,
},
machine,
} satisfies V3TaskRunContext;
@@ -1,6 +1,7 @@
import type { ClickHouse } from "@internal/clickhouse";
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
import {
type Prisma,
type PrismaClientOrTransaction,
type RuntimeEnvironmentType,
type TaskRunStatus,
@@ -8,13 +9,48 @@ import {
} from "@trigger.dev/database";
import { inferSchema } from "@jsonhero/schema-infer";
import parse from "parse-duration";
import { type RunStore } from "@internal/run-store";
import { type PrismaClient } from "~/db.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { getTimezones } from "~/utils/timezones.server";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
import { runStore } from "~/v3/runStore.server";
import { runStore as defaultRunStore } from "~/v3/runStore.server";
import { queueTypeFromType } from "./QueueRetrievePresenter.server";
// Optional run-ops read-through wiring for the recent-payloads hydrate. Omitted
// => passthrough on `this.replica` (single-DB / self-host). `legacyReplica` is a
// READ REPLICA handle only — there is no legacy-primary field.
type TestTaskReadThroughDeps = {
newClient?: PrismaClientOrTransaction;
legacyReplica?: PrismaClientOrTransaction;
// Resolved boot constant; when false the split branch is never entered.
splitEnabled?: boolean;
};
// The byte-identical select the recent-payloads hydrate has always used; `id` is
// included so the split merge can key set-membership.
const RECENT_RUNS_SELECT = {
id: true,
queue: true,
friendlyId: true,
taskIdentifier: true,
createdAt: true,
status: true,
payload: true,
payloadType: true,
seedMetadata: true,
seedMetadataType: true,
runtimeEnvironmentId: true,
concurrencyKey: true,
maxAttempts: true,
maxDurationInSeconds: true,
machinePreset: true,
ttl: true,
runTags: true,
} as const;
type RecentRunRow = Prisma.TaskRunGetPayload<{ select: typeof RECENT_RUNS_SELECT }>;
export type RunTemplate = TaskRunTemplate & {
scheduledTaskPayload?: ScheduledRun["payload"];
};
@@ -122,7 +158,9 @@ export type ScheduledRun = Omit<RawRun, "payload" | "ttl"> & {
export class TestTaskPresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
private readonly clickhouse: ClickHouse,
private readonly readThrough?: TestTaskReadThroughDeps,
private readonly runStore: RunStore = defaultRunStore
) {}
public async call({
@@ -215,41 +253,7 @@ export class TestTaskPresenter {
},
});
const latestRuns = await runStore.findRuns(
{
where: {
id: {
in: runIds,
},
payloadType: {
in: ["application/json", "application/super+json"],
},
},
select: {
id: true,
queue: true,
friendlyId: true,
taskIdentifier: true,
createdAt: true,
status: true,
payload: true,
payloadType: true,
seedMetadata: true,
seedMetadataType: true,
runtimeEnvironmentId: true,
concurrencyKey: true,
maxAttempts: true,
maxDurationInSeconds: true,
machinePreset: true,
ttl: true,
runTags: true,
},
orderBy: {
createdAt: "desc",
},
},
this.replica
);
const latestRuns = await this.hydrateRecentRuns(runIds);
// Infer schema from existing run payloads when no explicit schema is defined
let inferredPayloadSchema: unknown | undefined;
@@ -386,6 +390,58 @@ export class TestTaskPresenter {
}
}
}
// Runs the recent-payloads find on one client, preserving the byte-identical
// select, the payloadType IN filter, and the createdAt-desc order on every
// store this hydrate touches.
private hydrateOnClient(
client: PrismaClientOrTransaction,
ids: string[]
): Promise<RecentRunRow[]> {
return this.runStore.findRuns(
{
where: {
id: { in: ids },
payloadType: { in: ["application/json", "application/super+json"] },
},
select: RECENT_RUNS_SELECT,
orderBy: { createdAt: "desc" },
},
client
);
}
// Hydrates the recent-payloads run-id set from the run-ops store. Split on: new
// client first, then the LEGACY READ REPLICA ONLY for ids that miss on new —
// never the legacy primary. Split off: one plain findRuns on `this.replica`.
private async hydrateRecentRuns(runIds: string[]): Promise<RecentRunRow[]> {
if (runIds.length === 0) {
return [];
}
if (!this.readThrough?.splitEnabled) {
return this.hydrateOnClient(this.readThrough?.newClient ?? this.replica, runIds);
}
const newClient = this.readThrough.newClient ?? this.replica;
const legacyReplica = this.readThrough.legacyReplica ?? this.replica;
const newRows = await this.hydrateOnClient(newClient, runIds);
const foundIds = new Set(newRows.map((r) => r.id));
// Probe every id that missed on new against the legacy read replica.
const toProbeLegacy = runIds.filter((id) => !foundIds.has(id));
const legacyRows = toProbeLegacy.length
? await this.hydrateOnClient(legacyReplica, toProbeLegacy)
: [];
// Re-impose createdAt-desc across the two finds to match single-DB ordering,
// with an id-desc tie-break so identical timestamps stay deterministic.
return [...newRows, ...legacyRows].sort((a, b) => {
const byCreatedAt = b.createdAt.getTime() - a.createdAt.getTime();
return byCreatedAt !== 0 ? byCreatedAt : a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
});
}
}
async function getScheduleTaskRunPayload(payload: string, payloadType: string) {
@@ -1,12 +1,11 @@
import parse from "parse-duration";
import {
Prisma,
type RunEngineVersion,
type RuntimeEnvironmentType,
type WaitpointStatus,
} from "@trigger.dev/database";
import { type Direction } from "~/components/ListPagination";
import { sqlDatabaseSchema } from "~/db.server";
import { type PrismaClientOrTransaction } from "~/db.server";
import { BasePresenter } from "./basePresenter.server";
import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters";
import { determineEngineVersion } from "~/v3/engineVersion.server";
@@ -15,6 +14,23 @@ import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
const DEFAULT_PAGE_SIZE = 25;
// Row shape returned by the raw MANUAL-waitpoint keyset scan. Named so both the
// scan closure and the #scanWaitpoints store-selection helper reference one type.
type WaitpointRow = {
id: string;
friendlyId: string;
status: WaitpointStatus;
completedAt: Date | null;
completedAfter: Date | null;
outputIsError: boolean;
idempotencyKey: string;
idempotencyKeyExpiresAt: Date | null;
inactiveIdempotencyKey: string | null;
userProvidedIdempotencyKey: boolean;
createdAt: Date;
tags: null | string[];
};
export type WaitpointListOptions = {
environment: {
id: string;
@@ -66,6 +82,21 @@ type Result =
};
export class WaitpointListPresenter extends BasePresenter {
// Optional run-ops read-routing. Omitted (single-DB / self-host) => every read
// goes through `_replica` exactly as today (passthrough). There is NO legacy
// writer/primary handle by construction — the legacy field is the read replica only.
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readRoute?: {
runOpsNew?: PrismaClientOrTransaction; // new run-ops client
runOpsLegacyReplica?: PrismaClientOrTransaction; // legacy run-ops READ REPLICA only — never the legacy primary
splitEnabled?: boolean; // resolved boot constant
}
) {
super(prismaClient, replicaClient);
}
public async call({
environment,
id,
@@ -133,91 +164,60 @@ export class WaitpointListPresenter extends BasePresenter {
const periodMs = period ? parse(period) : undefined;
// Get the waitpoint tokens using raw SQL for better performance
const tokens = await this._replica.$queryRaw<
{
id: string;
friendlyId: string;
status: WaitpointStatus;
completedAt: Date | null;
completedAfter: Date | null;
outputIsError: boolean;
idempotencyKey: string;
idempotencyKeyExpiresAt: Date | null;
inactiveIdempotencyKey: string | null;
userProvidedIdempotencyKey: boolean;
createdAt: Date;
tags: null | string[];
}[]
>`
SELECT
w.id,
w."friendlyId",
w.status,
w."completedAt",
w."completedAfter",
w."outputIsError",
w."idempotencyKey",
w."idempotencyKeyExpiresAt",
w."inactiveIdempotencyKey",
w."userProvidedIdempotencyKey",
w."tags",
w."createdAt"
FROM
${sqlDatabaseSchema}."Waitpoint" w
WHERE
w."environmentId" = ${environment.id}
AND w.type = 'MANUAL'
-- cursor
${
cursor
? direction === "forward"
? Prisma.sql`AND w.id < ${cursor}`
: Prisma.sql`AND w.id > ${cursor}`
: Prisma.empty
}
-- filters
${id ? Prisma.sql`AND w."friendlyId" = ${id}` : Prisma.empty}
${
statusesToFilter && statusesToFilter.length > 0
? Prisma.sql`AND w.status = ANY(ARRAY[${Prisma.join(
statusesToFilter
)}]::"WaitpointStatus"[])`
: Prisma.empty
}
${
filterOutputIsError !== undefined
? Prisma.sql`AND w."outputIsError" = ${filterOutputIsError}`
: Prisma.empty
}
${
idempotencyKey
? Prisma.sql`AND (w."idempotencyKey" = ${idempotencyKey} OR w."inactiveIdempotencyKey" = ${idempotencyKey})`
: Prisma.empty
}
${
periodMs
? Prisma.sql`AND w."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
: Prisma.empty
}
${
from
? Prisma.sql`AND w."createdAt" >= ${new Date(from).toISOString()}::timestamp`
: Prisma.empty
}
${
to
? Prisma.sql`AND w."createdAt" <= ${new Date(to).toISOString()}::timestamp`
: Prisma.empty
}
${
tags && tags.length > 0
? Prisma.sql`AND w."tags" && ARRAY[${Prisma.join(tags)}]::text[]`
: Prisma.empty
}
ORDER BY
${direction === "forward" ? Prisma.sql`w.id DESC` : Prisma.sql`w.id ASC`}
LIMIT ${pageSize + 1}`;
let createdAtGte: Date | undefined;
if (periodMs != null) {
createdAtGte = new Date(Date.now() - periodMs);
}
if (from !== undefined) {
const fromDate = new Date(from);
createdAtGte =
createdAtGte === undefined ? fromDate : fromDate > createdAtGte ? fromDate : createdAtGte;
}
const createdAtLte: Date | undefined = to !== undefined ? new Date(to) : undefined;
const tokens = await this.#scanWaitpoints(
(client) =>
client.waitpoint.findMany({
where: {
environmentId: environment.id,
type: "MANUAL",
...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}),
...(id ? { friendlyId: id } : {}),
...(statusesToFilter.length ? { status: { in: statusesToFilter } } : {}),
...(filterOutputIsError !== undefined ? { outputIsError: filterOutputIsError } : {}),
...(idempotencyKey
? { OR: [{ idempotencyKey }, { inactiveIdempotencyKey: idempotencyKey }] }
: {}),
...(createdAtGte !== undefined || createdAtLte !== undefined
? {
createdAt: {
...(createdAtGte !== undefined ? { gte: createdAtGte } : {}),
...(createdAtLte !== undefined ? { lte: createdAtLte } : {}),
},
}
: {}),
...(tags && tags.length > 0 ? { tags: { hasSome: tags } } : {}),
},
orderBy: { id: direction === "forward" ? "desc" : "asc" },
take: pageSize + 1,
select: {
id: true,
friendlyId: true,
status: true,
completedAt: true,
completedAfter: true,
outputIsError: true,
idempotencyKey: true,
idempotencyKeyExpiresAt: true,
inactiveIdempotencyKey: true,
userProvidedIdempotencyKey: true,
tags: true,
createdAt: true,
},
}),
pageSize,
direction
);
const hasMore = tokens.length > pageSize;
@@ -249,16 +249,7 @@ export class WaitpointListPresenter extends BasePresenter {
let hasAnyTokens = tokensToReturn.length > 0;
if (!hasAnyTokens) {
const firstToken = await this._replica.waitpoint.findFirst({
where: {
environmentId: environment.id,
type: "MANUAL",
},
});
if (firstToken) {
hasAnyTokens = true;
}
hasAnyTokens = await this.#probeAnyToken(environment.id);
}
return {
@@ -296,6 +287,76 @@ export class WaitpointListPresenter extends BasePresenter {
},
};
}
// Run-ops reads for the Waitpoint-token dashboard. Split on: new DB first, then
// the LEGACY READ REPLICA ONLY for the not-yet-migrated remainder — never the
// legacy primary. Split off: one plain `_replica` read.
async #scanWaitpoints(
scan: (client: PrismaClientOrTransaction) => Promise<WaitpointRow[]>,
pageSize: number,
direction: Direction
): Promise<WaitpointRow[]> {
if (!this.readRoute?.splitEnabled) {
return scan(this._replica);
}
const overfetch = pageSize + 1;
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica);
// New DB filled the page => any older tokens fall on a later page; keep the
// legacy read off the hot path. Presence on the new DB is the migrated signal.
if (newRows.length >= overfetch) {
return newRows;
}
// READ REPLICA handle only (there is no writer/primary field on readRoute).
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? this._replica);
// Merge under keyset order: de-dupe by id keeping the new-DB copy as
// authoritative, re-sort in the page's direction, re-apply the over-fetch
// window so the result matches a single union scan.
const byId = new Map<string, WaitpointRow>();
for (const row of newRows) {
byId.set(row.id, row);
}
for (const row of legacyRows) {
if (!byId.has(row.id)) {
byId.set(row.id, row);
}
}
const merged = Array.from(byId.values());
merged.sort((a, b) =>
direction === "forward" ? compareIdDesc(a.id, b.id) : compareIdAsc(a.id, b.id)
);
return merged.slice(0, overfetch);
}
// Empty-state probe: two-handle existence check (no single runId, so not
// readThroughRun). New DB first, then the LEGACY read replica in split mode so
// the empty-state never reports false-empty during migration.
async #probeAnyToken(environmentId: string): Promise<boolean> {
const onNew = await (this.readRoute?.runOpsNew ?? this._replica).waitpoint.findFirst({
where: { environmentId, type: "MANUAL" },
});
if (onNew) return true;
if (!this.readRoute?.splitEnabled) return false;
const onLegacy = await (
this.readRoute.runOpsLegacyReplica ?? this._replica
).waitpoint.findFirst({
where: { environmentId, type: "MANUAL" },
});
return Boolean(onLegacy);
}
}
function compareIdAsc(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function compareIdDesc(a: string, b: string): number {
return a < b ? 1 : a > b ? -1 : 0;
}
export function waitpointStatusToApiStatus(
@@ -1,7 +1,10 @@
import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3";
import { type PrismaClientOrTransaction, type PrismaReplicaClient } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
import { logger } from "~/services/logger.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
import { BasePresenter } from "./basePresenter.server";
import { NextRunListPresenter, type NextRunListItem } from "./NextRunListPresenter.server";
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
@@ -9,6 +12,74 @@ import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
export type WaitpointDetail = NonNullable<Awaited<ReturnType<WaitpointPresenter["call"]>>>;
export class WaitpointPresenter extends BasePresenter {
constructor(
prisma?: PrismaClientOrTransaction,
replica?: PrismaClientOrTransaction,
private readonly readThroughDeps?: {
// The new run-ops client + the legacy run-ops read replica (never the legacy writer).
// Omitted => single-DB / self-host: both default to `_replica` (passthrough).
newClient?: PrismaClientOrTransaction;
legacyReplica?: PrismaClientOrTransaction;
// Resolved boot constant from isSplitEnabled(). When false/absent:
// the waitpoint lookup is one plain findFirst and the connected-runs hydrate runs passthrough.
splitEnabled?: boolean;
}
) {
super(prisma, replica);
}
async #findWaitpoint(friendlyId: string, environmentId: string) {
const where = { friendlyId, environmentId };
const select = {
id: true,
friendlyId: true,
type: true,
status: true,
idempotencyKey: true,
userProvidedIdempotencyKey: true,
idempotencyKeyExpiresAt: true,
inactiveIdempotencyKey: true,
output: true,
outputType: true,
outputIsError: true,
completedAfter: true,
completedAt: true,
createdAt: true,
connectedRuns: {
select: {
friendlyId: true,
},
take: 5,
},
tags: true,
environmentId: true,
} as const;
const hydrate = (client: PrismaReplicaClient) => client.waitpoint.findFirst({ where, select });
if (!this.readThroughDeps) {
return this._replica.waitpoint.findFirst({ where, select });
}
const result = await readThroughRun({
runId: friendlyId,
environmentId,
readNew: (client) => hydrate(client),
readLegacy: (replica) => hydrate(replica),
deps: {
splitEnabled: this.readThroughDeps.splitEnabled,
newClient:
(this.readThroughDeps.newClient as PrismaReplicaClient | undefined) ??
(this._replica as unknown as PrismaReplicaClient),
legacyReplica:
(this.readThroughDeps.legacyReplica as PrismaReplicaClient | undefined) ??
(this._replica as unknown as PrismaReplicaClient),
},
});
return result.source === "new" || result.source === "legacy-replica" ? result.value : null;
}
public async call({
friendlyId,
environmentId,
@@ -18,41 +89,7 @@ export class WaitpointPresenter extends BasePresenter {
environmentId: string;
projectId: string;
}) {
const waitpoint = await this._replica.waitpoint.findFirst({
where: {
friendlyId,
environmentId,
},
select: {
id: true,
friendlyId: true,
type: true,
status: true,
idempotencyKey: true,
userProvidedIdempotencyKey: true,
idempotencyKeyExpiresAt: true,
inactiveIdempotencyKey: true,
output: true,
outputType: true,
outputIsError: true,
completedAfter: true,
completedAt: true,
createdAt: true,
connectedRuns: {
select: {
friendlyId: true,
},
take: 5,
},
tags: true,
environment: {
select: {
apiKey: true,
organizationId: true,
},
},
},
});
const waitpoint = await this.#findWaitpoint(friendlyId, environmentId);
if (!waitpoint) {
logger.error(`WaitpointPresenter: Waitpoint not found`, {
@@ -61,6 +98,13 @@ export class WaitpointPresenter extends BasePresenter {
return null;
}
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(waitpoint.environmentId);
if (!environment) {
logger.error(`WaitpointPresenter: environment not found`, { friendlyId });
return null;
}
const output =
waitpoint.outputType === "application/store"
? `/resources/packets/${environmentId}/${waitpoint.output}`
@@ -80,20 +124,26 @@ export class WaitpointPresenter extends BasePresenter {
if (connectedRunIds.length > 0) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
waitpoint.environment.organizationId,
environment.organizationId,
"standard"
);
const runPresenter = new NextRunListPresenter(this._prisma, clickhouse);
const { runs } = await runPresenter.call(
waitpoint.environment.organizationId,
environmentId,
{
projectId: projectId,
runId: connectedRunIds,
pageSize: 5,
period: "31d",
}
const runPresenter = new NextRunListPresenter(
this._prisma,
clickhouse,
this.readThroughDeps
? {
newClient: this.readThroughDeps.newClient ?? this._replica,
legacyReplica: this.readThroughDeps.legacyReplica ?? this._replica,
splitEnabled: this.readThroughDeps.splitEnabled ?? false,
}
: undefined
);
const { runs } = await runPresenter.call(environment.organizationId, environmentId, {
projectId: projectId,
runId: connectedRunIds,
pageSize: 5,
period: "31d",
});
connectedRuns.push(...runs);
}
@@ -101,7 +151,7 @@ export class WaitpointPresenter extends BasePresenter {
return {
id: waitpoint.friendlyId,
type: waitpoint.type,
url: generateHttpCallbackUrl(waitpoint.id, waitpoint.environment.apiKey),
url: generateHttpCallbackUrl(waitpoint.id, environment.apiKey),
status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError),
idempotencyKey: waitpoint.idempotencyKey,
userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey,
@@ -1,3 +1,4 @@
import { type PrismaClientOrTransaction } from "~/db.server";
import { BasePresenter } from "./basePresenter.server";
export type TagListOptions = {
@@ -13,7 +14,32 @@ const DEFAULT_PAGE_SIZE = 25;
export type TagList = Awaited<ReturnType<WaitpointTagListPresenter["call"]>>;
export type TagListItem = TagList["tags"][number];
type WaitpointTagRow = {
id: string;
name: string;
};
type TagFindManyArgs = NonNullable<
Parameters<PrismaClientOrTransaction["waitpointTag"]["findMany"]>[0]
>;
type TagQuery = {
where: TagFindManyArgs["where"];
orderBy: TagFindManyArgs["orderBy"];
};
export class WaitpointTagListPresenter extends BasePresenter {
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readRoute?: {
runOpsNew?: PrismaClientOrTransaction;
runOpsLegacyReplica?: PrismaClientOrTransaction; // READ REPLICA only — never the legacy primary
splitEnabled?: boolean;
}
) {
super(prismaClient, replicaClient);
}
public async call({
environmentId,
name,
@@ -21,23 +47,17 @@ export class WaitpointTagListPresenter extends BasePresenter {
pageSize = DEFAULT_PAGE_SIZE,
}: TagListOptions) {
const hasFilters = Boolean(name?.trim());
const skip = (page - 1) * pageSize;
const tags = await this._replica.waitpointTag.findMany({
const query: TagQuery = {
where: {
environmentId,
name: name
? {
startsWith: name,
mode: "insensitive",
}
: undefined,
name: name ? { startsWith: name, mode: "insensitive" } : undefined,
},
orderBy: {
id: "desc",
},
take: pageSize + 1,
skip: (page - 1) * pageSize,
});
orderBy: { id: "desc" },
};
const tags = await this.#scanTags(query, skip, pageSize);
return {
tags: tags
@@ -50,4 +70,40 @@ export class WaitpointTagListPresenter extends BasePresenter {
hasFilters,
};
}
async #scanTags(query: TagQuery, skip: number, pageSize: number): Promise<WaitpointTagRow[]> {
const scan = (client: PrismaClientOrTransaction, take: number, offset: number) =>
client.waitpointTag.findMany({ ...query, take, skip: offset });
if (!this.readRoute?.splitEnabled) {
return scan(this._replica, pageSize + 1, skip);
}
const prefixSize = skip + pageSize + 1;
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica, prefixSize, 0);
// New DB filled the prefix => any older tags fall on a later page; skip the
// legacy read entirely. Presence on the new DB is the migrated signal.
if (newRows.length >= prefixSize) {
return newRows.slice(skip, prefixSize);
}
const legacyRows = await scan(
this.readRoute.runOpsLegacyReplica ?? this._replica,
prefixSize,
0
);
const byId = new Map<string, WaitpointTagRow>();
for (const row of newRows) byId.set(row.id, row);
for (const row of legacyRows) {
if (!byId.has(row.id)) byId.set(row.id, row);
}
const merged = Array.from(byId.values());
merged.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
return merged.slice(skip, skip + pageSize + 1);
}
}
@@ -0,0 +1,548 @@
import { describe, expect, vi } from "vitest";
// The SpanPresenter module graph imports `~/v3/runStore.server`, which imports `~/db.server`
// at load (and a large transitive graph: runEngine, eventRepository, mollifier, ...). We stub the
// two boundaries the presenter reads through so the file loads under test, then drive it entirely
// against real Postgres containers — NEVER mocking a DB client.
//
// * `~/db.server` — the module-level `prisma`/`$replica` exports. The presenter receives its
// control-plane handle through the BasePresenter constructor (`new SpanPresenter(cp, cp)`), so
// these stubs are never read on the path under test.
// * `~/v3/runStore.server` — the run-ops store singleton. This is the ONE wiring boundary we
// override: the test injects a routing-shaped store (a RoutingRunStore over the two-DB hetero
// fixture) in its place. This is a wiring override, not a DB mock — every run-ops read still
// executes against a real container.
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
}));
const routingStoreRef = vi.hoisted(() => ({ current: undefined as unknown }));
vi.mock("~/v3/runStore.server", () => ({
get runStore() {
return routingStoreRef.current;
},
}));
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
import type { RunStore } from "@internal/run-store";
import { heteroPostgresTest } from "@internal/testcontainers";
import type { Prisma, PrismaClient } from "@trigger.dev/database";
import { SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
vi.setConfig({ testTimeout: 90_000 });
// 25-char internal id → cuid → LEGACY; 27-char internal id → ksuid → NEW (the residency
// classifier shared with the RoutingRunStore's default `ownerEngine`).
const CUID_25 = "c".repeat(25);
const KSUID_27 = "k".repeat(27);
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
};
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `sc-${slug}`,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: runtimeEnvironment.id,
};
}
/** Mirror the org/project/env parents onto a second DB with the SAME ids (TaskRun FKs need them
* on every DB a run is hydrated from). */
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
await prisma.organization.create({
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: ctx.projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: ctx.organizationId,
externalRef: `proj-${slug}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environmentId,
slug: `env-${slug}`,
type: "PRODUCTION",
projectId: ctx.projectId,
organizationId: ctx.organizationId,
apiKey: `tr_prod_${slug}_b`,
pkApiKey: `pk_prod_${slug}_b`,
shortcode: `sc-${slug}-b`,
},
});
}
async function createRun(
prisma: PrismaClient,
ctx: SeedContext,
run: {
id: string;
friendlyId: string;
spanId: string;
parentSpanId?: string;
taskIdentifier?: string;
status?: Prisma.TaskRunCreateInput["status"];
parentTaskRunId?: string;
rootTaskRunId?: string;
}
) {
return prisma.taskRun.create({
data: {
id: run.id,
friendlyId: run.friendlyId,
taskIdentifier: run.taskIdentifier ?? "my-task",
status: run.status ?? "COMPLETED_SUCCESSFULLY",
payload: JSON.stringify({ foo: run.friendlyId }),
payloadType: "application/json",
traceId: `trace_${run.friendlyId}`,
spanId: run.spanId,
parentSpanId: run.parentSpanId,
parentTaskRunId: run.parentTaskRunId,
rootTaskRunId: run.rootTaskRunId,
queue: "task/my-task",
runTags: ["alpha", "beta"],
runtimeEnvironmentId: ctx.environmentId,
projectId: ctx.projectId,
organizationId: ctx.organizationId,
environmentType: "PRODUCTION",
engine: "V2",
taskEventStore: "taskEvent",
},
});
}
/**
* Test-only wiring shim. In production the run-ops store's DB selection is the store's own
* concern, but `SpanPresenter` still passes `this._replica`/`this._prisma` (the control-plane
* handle) as the explicit `client` arg to `runStore.findRun`/`findRuns`. `PostgresRunStore`
* honours an explicit client (`client ?? this.readOnlyPrisma`), so without this shim a run-ops
* read would execute against the control-plane DB. Reconciling that explicit-client override
* with split routing is the job of the runStore.server.ts wiring seam, explicitly OUT of this
* unit's scope. The shim represents that reconciliation: it drops the
* presenter's client arg so each underlying PostgresRunStore reads from its OWN bound DB the
* residency-routed behaviour the presenter will inherit once the seam is wired. It fakes ONLY the
* client wiring; every DB read still hits a real container.
*/
function ownDbStore(prisma: PrismaClient): RunStore {
const inner = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
return new Proxy(inner, {
get(target, prop) {
if (prop === "findRun" || prop === "findRuns") {
return (...args: unknown[]) => {
// Strip a trailing explicit `client` arg so the store reads from its own DB.
const stripped = stripTrailingClient(prop, args);
return (target[prop] as (...a: unknown[]) => unknown).apply(target, stripped);
};
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as unknown as RunStore;
}
function stripTrailingClient(method: "findRun" | "findRuns", args: unknown[]): unknown[] {
// findRun(where, argsOrClient?, client?) ; findRuns(args, client?). The last arg is the
// presenter's explicit client when it is not a projection object.
const last = args[args.length - 1] as { select?: unknown; include?: unknown } | undefined;
const isProjection =
typeof last === "object" && last !== null && ("select" in last || "include" in last);
if (args.length === 0 || isProjection) {
return args;
}
return args.slice(0, -1);
}
/** A read-only client wrapper: throws on any write, asserting the legacy slot is replica-only. */
function asReplica(prisma: PrismaClient): PrismaClient {
return new Proxy(prisma, {
get(target, prop, receiver) {
if (prop === "taskRun") {
return new Proxy((target as any).taskRun, {
get(trTarget, trProp) {
if (
["create", "update", "updateMany", "upsert", "delete", "deleteMany"].includes(
String(trProp)
)
) {
return () => {
throw new Error(`legacy slot is read-replica-only; ${String(trProp)} is forbidden`);
};
}
return (trTarget as any)[trProp];
},
});
}
return Reflect.get(target, prop, receiver);
},
}) as unknown as PrismaClient;
}
describe("SpanPresenter run-ops/control-plane partition (legacy + new)", () => {
// Span detail resolves run + children through the run-ops store, region/schedule/session
// on control-plane, no cross-DB join.
heteroPostgresTest(
"findRun hydrates the run through the run-ops store (new-first) and the children-by-parentSpanId set; region/schedule/session resolve from the control-plane client",
async ({ prisma14, prisma17 }) => {
// prisma17 = NEW run-ops; prisma14 = LEGACY run-ops replica AND, for this partition proof,
// the control-plane DB (a physically distinct DB from the NEW run-ops store).
const cp = prisma14;
// Seed the env/project/org parents on BOTH run-ops DBs (FKs) and on the CP DB.
const ctxNew = await seedParents(prisma17, "partn");
await mirrorParents(prisma14, ctxNew, "partn"); // legacy run-ops + CP parents share ids
const runId = `run_${KSUID_27}`; // ksuid → NEW residency
const childMigratedId = `run_a${KSUID_27.slice(1)}`; // also NEW
await createRun(prisma17, ctxNew, {
id: runId,
friendlyId: "run_parent",
spanId: "span_parent",
taskIdentifier: "parent-task",
});
// A child whose parentSpanId points at the parent's span — lives on NEW.
await createRun(prisma17, ctxNew, {
id: childMigratedId,
friendlyId: "run_child_new",
spanId: "span_child_new",
parentSpanId: "span_parent",
parentTaskRunId: runId,
taskIdentifier: "child-task",
});
// Control-plane rows live on the CP DB only.
const workerGroup = await cp.workerInstanceGroup.create({
data: {
name: "us-east-1-group",
location: "N. Virginia, USA",
masterQueue: "main",
type: "MANAGED",
token: { create: { tokenHash: `tok-${ctxNew.projectId}` } },
},
});
const schedule = await cp.taskSchedule.create({
data: {
friendlyId: "sched_1234",
taskIdentifier: "parent-task",
projectId: ctxNew.projectId,
deduplicationKey: "dedup-1",
type: "DECLARATIVE",
generatorExpression: "0 * * * *",
generatorDescription: "every hour",
timezone: "UTC",
},
});
routingStoreRef.current = new RoutingRunStore({
new: ownDbStore(prisma17),
legacy: ownDbStore(prisma14),
});
const presenter = new SpanPresenter(cp, cp);
// (a) run hydrated through the run-ops store (NEW), byte-identical to the source row incl.
// the run-ops self-relations.
const run = await presenter.findRun({
originalRunId: "run_parent",
spanId: "span_parent",
environmentId: ctxNew.environmentId,
});
expect(run?.id).toBe(runId);
expect(run?.friendlyId).toBe("run_parent");
expect(run?.taskIdentifier).toBe("parent-task");
expect(run?.runTags).toEqual(["alpha", "beta"]);
// Nested run-ops self-relation resolved on the same (NEW) store.
expect(run?.parentTaskRun).toBeNull();
// (b) the run does NOT exist on the CP DB — the run-ops read could only have come from the
// run-ops store, never a CP join.
expect(await cp.taskRun.findFirst({ where: { friendlyId: "run_parent" } })).toBeNull();
// (c) the control-plane standalone reads resolve from the CP client.
const region = await cp.workerInstanceGroup.findFirst({ where: { masterQueue: "main" } });
expect(region?.name).toBe(workerGroup.name);
expect(await presenter.resolveSchedule(schedule.id)).toMatchObject({
friendlyId: "sched_1234",
timezone: "UTC",
});
}
);
// Children set served by runStore.findRuns through the routing store.
heteroPostgresTest(
"triggeredRuns (children-by-parentSpanId) is served by runStore.findRuns with the presenter's exact select",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma17, "kids");
await createRun(prisma17, ctx, {
id: `run_${KSUID_27}`,
friendlyId: "run_parent2",
spanId: "span_p2",
});
await createRun(prisma17, ctx, {
id: `run_b${KSUID_27.slice(1)}`,
friendlyId: "run_kid_a",
spanId: "span_kid_a",
parentSpanId: "span_p2",
});
await createRun(prisma17, ctx, {
id: `run_c${KSUID_27.slice(1)}`,
friendlyId: "run_kid_b",
spanId: "span_kid_b",
parentSpanId: "span_p2",
});
const store = new RoutingRunStore({
new: ownDbStore(prisma17),
legacy: ownDbStore(prisma14),
});
const triggeredRuns = await store.findRuns({
where: { parentSpanId: "span_p2" },
select: {
friendlyId: true,
taskIdentifier: true,
spanId: true,
createdAt: true,
status: true,
},
});
expect(triggeredRuns.map((r) => r.friendlyId).sort()).toEqual(["run_kid_a", "run_kid_b"]);
// select projection holds: no `id`/`payload` leaked through.
expect(triggeredRuns[0]).not.toHaveProperty("id");
expect(triggeredRuns[0]).not.toHaveProperty("payload");
}
);
// Old in-retention run served from the legacy replica, never the primary.
heteroPostgresTest(
"a legacy-residency run resolves through the store's LEGACY slot, which exposes only a replica handle",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "legacy");
const legacyRunId = `run_${CUID_25}`; // cuid → LEGACY residency
await createRun(prisma14, ctx, {
id: legacyRunId,
friendlyId: "run_legacy",
spanId: "span_legacy",
taskIdentifier: "legacy-task",
});
// The LEGACY slot is wired over a replica (read-only) handle; the NEW slot over the new DB.
const store = new RoutingRunStore({
new: ownDbStore(prisma17),
legacy: ownDbStore(asReplica(prisma14)),
});
// Routed by `id` residency (cuid → LEGACY). The presenter's findRun keys by friendlyId/spanId
// (which route NEW-default through the store today); routing by id is the store-level proof
// that the LEGACY slot serves in-retention runs. The legacy slot's replica handle forbids
// writes — proving the read route can never touch a legacy writer.
const found = await store.findRun(
{ id: legacyRunId },
{ select: { id: true, friendlyId: true, taskIdentifier: true } }
);
expect(found?.id).toBe(legacyRunId);
expect(found?.taskIdentifier).toBe("legacy-task");
}
);
// A known-migrated run is not re-probed on legacy.
heteroPostgresTest(
"a NEW-residency id is served by the NEW slot and the LEGACY slot is never invoked",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma17, "knownmig");
const newRunId = `run_${KSUID_27}`; // ksuid → NEW residency
await createRun(prisma17, ctx, {
id: newRunId,
friendlyId: "run_known_new",
spanId: "span_known_new",
taskIdentifier: "new-task",
});
// LEGACY slot throws on ANY read — asserting the residency short-circuit never probes it.
const legacyThrows = new Proxy({} as RunStore, {
get(_t, prop) {
if (prop === "findRun" || prop === "findRuns") {
return () => {
throw new Error(`LEGACY slot must not be probed for a NEW id (${String(prop)})`);
};
}
return undefined;
},
});
const store = new RoutingRunStore({
new: ownDbStore(prisma17),
legacy: legacyThrows,
});
const found = await store.findRun(
{ id: newRunId },
{ select: { id: true, taskIdentifier: true } }
);
expect(found?.id).toBe(newRunId);
expect(found?.taskIdentifier).toBe("new-task");
}
);
// Passthrough (single-DB): NEW and LEGACY slots are the same store over one client.
heteroPostgresTest(
"single-DB collapses both slots to one PostgresRunStore; the presenter resolves run + children + control-plane from the one client",
async ({ prisma14 }) => {
const cp = prisma14;
const ctx = await seedParents(prisma14, "passthru");
const runId = `run_${KSUID_27}`;
await createRun(prisma14, ctx, {
id: runId,
friendlyId: "run_solo",
spanId: "span_solo",
taskIdentifier: "solo-task",
});
await createRun(prisma14, ctx, {
id: `run_d${KSUID_27.slice(1)}`,
friendlyId: "run_solo_kid",
spanId: "span_solo_kid",
parentSpanId: "span_solo",
});
const schedule = await cp.taskSchedule.create({
data: {
friendlyId: "sched_solo",
taskIdentifier: "solo-task",
projectId: ctx.projectId,
deduplicationKey: "dedup-solo",
type: "DECLARATIVE",
generatorExpression: "0 * * * *",
generatorDescription: "every hour",
timezone: "UTC",
},
});
// Both slots are the same store over the one client — the single-DB collapse.
const solo = ownDbStore(prisma14);
routingStoreRef.current = new RoutingRunStore({ new: solo, legacy: solo });
const presenter = new SpanPresenter(cp, cp);
const run = await presenter.findRun({
originalRunId: "run_solo",
spanId: "span_solo",
environmentId: ctx.environmentId,
});
expect(run?.id).toBe(runId);
expect(run?.taskIdentifier).toBe("solo-task");
// Children resolve from the same single store.
const children = await (routingStoreRef.current as RunStore).findRuns({
where: { parentSpanId: "span_solo" },
select: {
friendlyId: true,
taskIdentifier: true,
spanId: true,
createdAt: true,
status: true,
},
});
expect(children.map((c) => c.friendlyId)).toEqual(["run_solo_kid"]);
// Control-plane read from the same single client.
expect(await presenter.resolveSchedule(schedule.id)).toMatchObject({
friendlyId: "sched_solo",
});
}
);
// Cross-seam tree shape: parent on LEGACY (in-retention), child on NEW (born-new).
heteroPostgresTest(
"parent run on the legacy replica, child run on new — relations resolve across the seam, no cross-DB join",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "e2e4");
await mirrorParents(prisma17, ctx, "e2e4");
const parentId = `run_${CUID_25}`; // cuid → LEGACY (in-retention)
const childId = `run_${KSUID_27}`; // ksuid → NEW (born-new)
await createRun(prisma14, ctx, {
id: parentId,
friendlyId: "run_e2e_parent",
spanId: "span_e2e_parent",
taskIdentifier: "parent",
rootTaskRunId: parentId,
});
// The child lives on NEW; it links to the parent across the seam ONLY by `parentSpanId`
// (a plain indexed column — the exact key `triggeredRuns` uses), NOT by a cross-DB FK
// (`parentTaskRunId`/`rootTaskRunId` would violate the FK since the parent is on LEGACY;
// a tree's FK self-relations stay single-DB).
await createRun(prisma17, ctx, {
id: childId,
friendlyId: "run_e2e_child",
spanId: "span_e2e_child",
parentSpanId: "span_e2e_parent",
taskIdentifier: "child",
});
const store = new RoutingRunStore({
new: ownDbStore(prisma17),
legacy: ownDbStore(asReplica(prisma14)),
});
// The parent resolves from the LEGACY slot (routed by its cuid id).
const parent = await store.findRun(
{ id: parentId },
{
select: {
id: true,
friendlyId: true,
rootTaskRun: { select: { friendlyId: true } },
},
}
);
expect(parent?.id).toBe(parentId);
// Run-ops self-relation (rootTaskRun) resolves on the parent's own (LEGACY) store — a
// tree's FK self-relations stay single-DB.
expect(parent?.rootTaskRun?.friendlyId).toBe("run_e2e_parent");
// The child resolves from the NEW slot (routed by its ksuid id) and points back at the parent
// span — the cross-the-line parent/child shape, with no cross-DB join.
const child = await store.findRun(
{ id: childId },
{ select: { id: true, parentSpanId: true, friendlyId: true } }
);
expect(child?.id).toBe(childId);
expect(child?.parentSpanId).toBe("span_e2e_parent");
}
);
});
@@ -0,0 +1,229 @@
// Route-level regression for ApiBatchResultsPresenter: the /batches/:id/results route used to build
// the presenter with no read-through deps, collapsing to a passthrough read off the control-plane
// replica only, which 404s a NEW-resident (ksuid) batch that lives on the dedicated run-ops DB.
import { heteroPostgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import type { PrismaReplicaClient } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
vi.setConfig({ testTimeout: 60_000 });
// 27-char body → NEW residency (ksuid analog). 25-char body → LEGACY residency (cuid analog).
function newRunId(c: string) {
return c.repeat(27);
}
// A prisma handle that throws on any access — proves the split path never reads the passthrough
// handles when the batch resolves off the NEW client.
const throwingPrisma = new Proxy(
{},
{
get(_t, prop) {
throw new Error(
`passthrough handle must not be touched on the split path (got .${String(prop)})`
);
},
}
) as unknown as PrismaReplicaClient;
let seedCounter = 0;
async function seedEnv(prisma: PrismaClient, slug: string) {
const n = seedCounter++;
const organization = await prisma.organization.create({
data: { title: `Org ${slug}`, slug: `org-${slug}-${n}` },
});
const project = await prisma.project.create({
data: {
name: `Proj ${slug}`,
slug: `proj-${slug}-${n}`,
organizationId: organization.id,
externalRef: `ext-${slug}-${n}`,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}-${n}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `api-${slug}-${n}`,
pkApiKey: `pk-${slug}-${n}`,
shortcode: `sc-${slug}-${n}`,
},
});
return { organization, project, environment };
}
type SeedCtx = Awaited<ReturnType<typeof seedEnv>>;
// Mirror the same org/project/env ids onto the second DB so a passthrough read against the
// control-plane replica has the environment row to filter on (but NOT the batch row).
async function mirrorEnv(prisma: PrismaClient, ctx: SeedCtx, slug: string) {
const n = seedCounter++;
await prisma.organization.create({
data: { id: ctx.organization.id, title: `Org ${slug}`, slug: `org-${slug}-m-${n}` },
});
await prisma.project.create({
data: {
id: ctx.project.id,
name: `Proj ${slug}`,
slug: `proj-${slug}-m-${n}`,
organizationId: ctx.organization.id,
externalRef: `ext-${slug}-m-${n}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environment.id,
slug: `env-${slug}-m-${n}`,
type: "PRODUCTION",
projectId: ctx.project.id,
organizationId: ctx.organization.id,
apiKey: `api-${slug}-m-${n}`,
pkApiKey: `pk-${slug}-m-${n}`,
shortcode: `sc-${slug}-m-${n}`,
},
});
}
// Drop the per-DB TaskRunAttempt worker/queue FKs so we can seed an attempt (its output is what the
// execution-result carries) without standing up BackgroundWorker/TaskQueue parents.
async function relaxFks(prisma: PrismaClient) {
for (const sql of [
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerId_fkey"`,
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerTaskId_fkey"`,
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_queueId_fkey"`,
]) {
await prisma.$executeRawUnsafe(sql);
}
}
async function seedMember(
prisma: PrismaClient,
ctx: SeedCtx,
m: { id: string; friendlyId: string; output: string }
) {
const run = await prisma.taskRun.create({
data: {
id: m.id,
friendlyId: m.friendlyId,
taskIdentifier: "my-task",
status: "COMPLETED_SUCCESSFULLY",
payload: JSON.stringify({}),
payloadType: "application/json",
traceId: m.id,
spanId: m.id,
queue: "main",
runtimeEnvironmentId: ctx.environment.id,
projectId: ctx.project.id,
organizationId: ctx.organization.id,
environmentType: "PRODUCTION",
engine: "V2",
},
});
await prisma.taskRunAttempt.create({
data: {
friendlyId: `attempt_${m.id}`,
number: 1,
taskRunId: run.id,
backgroundWorkerId: "bw",
backgroundWorkerTaskId: "bwt",
runtimeEnvironmentId: ctx.environment.id,
queueId: "q",
status: "COMPLETED",
output: m.output,
outputType: "application/json",
},
});
return run;
}
async function seedBatch(
prisma: PrismaClient,
ctx: SeedCtx,
friendlyId: string,
memberIds: string[]
) {
const batch = await prisma.batchTaskRun.create({
data: {
friendlyId,
runtimeEnvironmentId: ctx.environment.id,
runCount: memberIds.length,
runIds: [],
batchVersion: "runengine:v2",
},
});
for (const taskRunId of memberIds) {
await prisma.batchTaskRunItem.create({
data: { batchTaskRunId: batch.id, taskRunId, status: "COMPLETED" },
});
}
return batch;
}
const env = (ctx: SeedCtx) =>
({
id: ctx.environment.id,
type: ctx.environment.type,
slug: ctx.environment.slug,
organizationId: ctx.organization.id,
organization: { slug: ctx.organization.slug, title: ctx.organization.title },
projectId: ctx.project.id,
project: { name: ctx.project.name },
}) as unknown as AuthenticatedEnvironment;
describe("ApiBatchResultsPresenter route wiring (the /batches/:id/results 404 regression)", () => {
heteroPostgresTest(
"a NEW-resident batch resolves with split deps but 404s (undefined) when built passthrough-only",
async ({ prisma14, prisma17 }) => {
const newDb = prisma17 as unknown as PrismaClient; // dedicated run-ops (NEW) DB analog
const legacyDb = prisma14 as unknown as PrismaClient; // control-plane / legacy replica analog
// Batch + members live ONLY on the NEW DB. The env is mirrored onto the legacy DB so the
// passthrough read has an environment to filter on — but never the batch row.
const ctx = await seedEnv(newDb, "route-new");
await mirrorEnv(legacyDb, ctx, "route-legacy");
await relaxFks(newDb);
const memberId = newRunId("a");
await seedMember(newDb, ctx, {
id: memberId,
friendlyId: "run_route_a",
output: JSON.stringify({ from: "new" }),
});
await seedBatch(newDb, ctx, "batch_route_new", [memberId]);
// Route wiring: splitEnabled + newClient + legacyReplica; passthrough handles throw.
const splitPresenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
});
const resolved = await splitPresenter.call("batch_route_new", env(ctx));
expect(resolved).toBeDefined();
expect(resolved!.id).toBe("batch_route_new");
expect(resolved!.items).toHaveLength(1);
expect(resolved!.items[0]).toEqual({
ok: true,
id: "run_route_a",
taskIdentifier: "my-task",
output: JSON.stringify({ from: "new" }),
outputType: "application/json",
});
// Pre-fix route: no read-through deps => passthrough off the control-plane replica (the legacy
// DB, which never received the batch) => undefined, i.e. the 404.
const passthroughPresenter = new ApiBatchResultsPresenter(legacyDb, legacyDb);
const missed = await passthroughPresenter.call("batch_route_new", env(ctx));
expect(missed).toBeUndefined();
}
);
});
@@ -0,0 +1,413 @@
// Read-through proof for ApiBatchResultsPresenter.
//
// The batch row + its item rows resolve new-run-ops-first then off the LEGACY run-ops READ
// REPLICA ONLY; each member run is hydrated independently via the per-run read-through primitive,
// so a batch whose members span migrated (NEW) + abandoned (LEGACY) runs returns the
// complete reachable set. Single-DB collapses to one passthrough read. We NEVER mock the DB — the
// only injected fakes are the pure boundaries (splitEnabled / isPastRetention).
//
// The BatchTaskRunItem -> TaskRun FK is per-DB; a batch straddling the seam references member ids
// that live on the other DB, so we drop that one FK on the batch's DB at seed time (the cross-seam
// reality where the item row survives while the member's authoritative row lives on the other DB).
import { PostgresRunStore } from "@internal/run-store";
import { heteroPostgresTest, postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import type { PrismaReplicaClient } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
vi.setConfig({ testTimeout: 60_000 });
// 27-char body → NEW residency (ksuid analog). 25-char body → LEGACY residency (cuid analog).
function newRunId(c: string) {
return c.repeat(27);
}
function legacyRunId(c: string) {
return c.repeat(25);
}
// A prisma handle that throws on any access — proves the split path never reads it.
const throwingPrisma = new Proxy(
{},
{
get(_t, prop) {
throw new Error(
`passthrough handle must not be touched on the split path (got .${String(prop)})`
);
},
}
) as unknown as PrismaReplicaClient;
let seedCounter = 0;
async function seedEnv(prisma: PrismaClient, slug: string) {
const n = seedCounter++;
const organization = await prisma.organization.create({
data: { title: `Org ${slug}`, slug: `org-${slug}-${n}` },
});
const project = await prisma.project.create({
data: {
name: `Proj ${slug}`,
slug: `proj-${slug}-${n}`,
organizationId: organization.id,
externalRef: `ext-${slug}-${n}`,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}-${n}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `api-${slug}-${n}`,
pkApiKey: `pk-${slug}-${n}`,
shortcode: `sc-${slug}-${n}`,
},
});
return { organization, project, environment };
}
type SeedCtx = Awaited<ReturnType<typeof seedEnv>>;
// Mirror the same org/project/env ids onto a second DB so member TaskRun FKs resolve there.
async function mirrorEnv(prisma: PrismaClient, ctx: SeedCtx, slug: string) {
const n = seedCounter++;
await prisma.organization.create({
data: { id: ctx.organization.id, title: `Org ${slug}`, slug: `org-${slug}-m-${n}` },
});
await prisma.project.create({
data: {
id: ctx.project.id,
name: `Proj ${slug}`,
slug: `proj-${slug}-m-${n}`,
organizationId: ctx.organization.id,
externalRef: `ext-${slug}-m-${n}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environment.id,
slug: `env-${slug}-m-${n}`,
type: "PRODUCTION",
projectId: ctx.project.id,
organizationId: ctx.organization.id,
apiKey: `api-${slug}-m-${n}`,
pkApiKey: `pk-${slug}-m-${n}`,
shortcode: `sc-${slug}-m-${n}`,
},
});
}
type MemberSeed = {
id: string;
friendlyId: string;
status: "COMPLETED_SUCCESSFULLY" | "COMPLETED_WITH_ERRORS";
output?: string;
error?: unknown;
};
async function seedMember(prisma: PrismaClient, ctx: SeedCtx, m: MemberSeed) {
const run = await prisma.taskRun.create({
data: {
id: m.id,
friendlyId: m.friendlyId,
taskIdentifier: "my-task",
status: m.status,
payload: JSON.stringify({}),
payloadType: "application/json",
traceId: m.id,
spanId: m.id,
queue: "main",
runtimeEnvironmentId: ctx.environment.id,
projectId: ctx.project.id,
organizationId: ctx.organization.id,
environmentType: "PRODUCTION",
engine: "V2",
},
});
await prisma.taskRunAttempt.create({
data: {
friendlyId: `attempt_${m.id}`,
number: 1,
taskRunId: run.id,
backgroundWorkerId: "bw",
backgroundWorkerTaskId: "bwt",
runtimeEnvironmentId: ctx.environment.id,
queueId: "q",
status: m.status === "COMPLETED_SUCCESSFULLY" ? "COMPLETED" : "FAILED",
output: m.output,
outputType: "application/json",
error: m.error as any,
},
});
return run;
}
// Drop the per-DB BatchTaskRunItem -> TaskRun FK so items on this DB can reference member ids whose
// authoritative TaskRun lives on the other DB (the cross-seam batch). Also drop the TaskRunAttempt
// worker/queue FKs so we can seed attempts (their output/error is what's under test) without
// standing up BackgroundWorker/TaskQueue parents — those rows are incidental to this read path.
async function relaxFks(prisma: PrismaClient) {
for (const sql of [
`ALTER TABLE "BatchTaskRunItem" DROP CONSTRAINT IF EXISTS "BatchTaskRunItem_taskRunId_fkey"`,
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerId_fkey"`,
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerTaskId_fkey"`,
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_queueId_fkey"`,
]) {
await prisma.$executeRawUnsafe(sql);
}
}
async function seedBatch(
prisma: PrismaClient,
ctx: SeedCtx,
friendlyId: string,
memberIds: string[]
) {
const batch = await prisma.batchTaskRun.create({
data: {
friendlyId,
runtimeEnvironmentId: ctx.environment.id,
runCount: memberIds.length,
runIds: [],
batchVersion: "runengine:v2",
},
});
// Items in a deterministic order so the result `items` order is assertable.
for (const taskRunId of memberIds) {
await prisma.batchTaskRunItem.create({
data: { batchTaskRunId: batch.id, taskRunId, status: "COMPLETED" },
});
}
return batch;
}
const env = (ctx: SeedCtx) =>
({
id: ctx.environment.id,
type: ctx.environment.type,
slug: ctx.environment.slug,
organizationId: ctx.organization.id,
organization: { slug: ctx.organization.slug, title: ctx.organization.title },
projectId: ctx.project.id,
project: { name: ctx.project.name },
}) as unknown as AuthenticatedEnvironment;
describe("ApiBatchResultsPresenter read-through (legacy + new DB)", () => {
// A batch with members on BOTH DBs returns the complete set, byte-identical.
heteroPostgresTest(
"members spanning NEW + legacy hydrate to the complete union, in item order",
async ({ prisma14, prisma17 }) => {
const newDb = prisma17 as unknown as PrismaClient;
const legacyDb = prisma14 as unknown as PrismaClient;
const ctx = await seedEnv(newDb, "span-new");
await mirrorEnv(legacyDb, ctx, "span-legacy");
await relaxFks(newDb);
await relaxFks(legacyDb);
const newMemberId = newRunId("a");
const legacyMemberId = legacyRunId("b");
// NEW member lives only on the new DB, legacy member only on the legacy DB.
await seedMember(newDb, ctx, {
id: newMemberId,
friendlyId: "run_new_a",
status: "COMPLETED_SUCCESSFULLY",
output: JSON.stringify({ from: "new" }),
});
await seedMember(legacyDb, ctx, {
id: legacyMemberId,
friendlyId: "run_legacy_b",
status: "COMPLETED_WITH_ERRORS",
error: { type: "BUILT_IN_ERROR", name: "Err", message: "boom", stackTrace: "" },
});
// The batch row + items live on the NEW DB; items reference both members.
await seedBatch(newDb, ctx, "batch_span", [newMemberId, legacyMemberId]);
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
});
const result = await presenter.call("batch_span", env(ctx));
expect(result).toBeDefined();
expect(result!.id).toBe("batch_span");
expect(result!.items).toHaveLength(2);
// Order follows item order: NEW member first, legacy member second.
const [first, second] = result!.items;
expect(first).toEqual({
ok: true,
id: "run_new_a",
taskIdentifier: "my-task",
output: JSON.stringify({ from: "new" }),
outputType: "application/json",
});
expect(second).toMatchObject({
ok: false,
id: "run_legacy_b",
taskIdentifier: "my-task",
});
}
);
// Batch row resident only on the legacy replica resolves via new-first-miss → legacy.
heteroPostgresTest(
"a legacy-resident batch row resolves off the legacy replica (new probe misses)",
async ({ prisma14, prisma17 }) => {
const newDb = prisma17 as unknown as PrismaClient;
const legacyDb = prisma14 as unknown as PrismaClient;
const ctx = await seedEnv(legacyDb, "lb-legacy");
await mirrorEnv(newDb, ctx, "lb-new");
await relaxFks(legacyDb);
const legacyMemberId = legacyRunId("c");
await seedMember(legacyDb, ctx, {
id: legacyMemberId,
friendlyId: "run_legacy_c",
status: "COMPLETED_SUCCESSFULLY",
output: JSON.stringify({ ok: 1 }),
});
// Batch row + items only on the legacy replica; absent from NEW.
await seedBatch(legacyDb, ctx, "batch_legacy", [legacyMemberId]);
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
});
const result = await presenter.call("batch_legacy", env(ctx));
expect(result).toBeDefined();
expect(result!.id).toBe("batch_legacy");
expect(result!.items).toHaveLength(1);
expect(result!.items[0]).toMatchObject({ ok: true, id: "run_legacy_c" });
}
);
// Past-retention / missing member is omitted (dangling-ref gate adjacent), not errored.
heteroPostgresTest(
"a member present on neither DB is omitted; the reachable members still return",
async ({ prisma14, prisma17 }) => {
const newDb = prisma17 as unknown as PrismaClient;
const legacyDb = prisma14 as unknown as PrismaClient;
const ctx = await seedEnv(newDb, "dangle-new");
await mirrorEnv(legacyDb, ctx, "dangle-legacy");
await relaxFks(newDb);
await relaxFks(legacyDb);
const presentId = newRunId("e");
const missingId = legacyRunId("f"); // referenced by an item but seeded on NO DB
await seedMember(newDb, ctx, {
id: presentId,
friendlyId: "run_present_e",
status: "COMPLETED_SUCCESSFULLY",
output: JSON.stringify({ present: true }),
});
await seedBatch(newDb, ctx, "batch_dangle", [presentId, missingId]);
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
});
const result = await presenter.call("batch_dangle", env(ctx));
expect(result).toBeDefined();
// The dangling member is dropped; the reachable member still returns. The dangling-reference
// termination gate (separate unit) governs whether such omission is permitted pre-termination.
expect(result!.items).toHaveLength(1);
expect(result!.items[0]).toMatchObject({ ok: true, id: "run_present_e" });
}
);
heteroPostgresTest(
"an absent batch friendlyId returns undefined (split on)",
async ({ prisma14, prisma17 }) => {
const ctx = await seedEnv(prisma17 as unknown as PrismaClient, "nf-new");
await mirrorEnv(prisma14 as unknown as PrismaClient, ctx, "nf-legacy");
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
});
const result = await presenter.call("batch_does_not_exist", env(ctx));
expect(result).toBeUndefined();
}
);
});
describe("ApiBatchResultsPresenter passthrough (single-DB collapse)", () => {
// Single-DB: one batch read + one store id-set hydrate; legacy boundaries never touched.
postgresTest(
"single-DB hydrates the full set and never reaches the read-through boundaries",
async ({ prisma }) => {
const ctx = await seedEnv(prisma, "pt");
await relaxFks(prisma);
const memberId = legacyRunId("g");
await seedMember(prisma, ctx, {
id: memberId,
friendlyId: "run_pt_g",
status: "COMPLETED_SUCCESSFULLY",
output: JSON.stringify({ value: 42 }),
});
await seedBatch(prisma, ctx, "batch_pt", [memberId]);
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
// Throwing legacy replica: if the split path is entered, it blows up.
const presenter = new ApiBatchResultsPresenter(
prisma,
prisma,
{
splitEnabled: false,
legacyReplica: throwingPrisma,
},
runStore
);
const result = await presenter.call("batch_pt", env(ctx));
expect(result).toBeDefined();
expect(result!.id).toBe("batch_pt");
expect(result!.items).toHaveLength(1);
expect(result!.items[0]).toEqual({
ok: true,
id: "run_pt_g",
taskIdentifier: "my-task",
output: JSON.stringify({ value: 42 }),
outputType: "application/json",
});
}
);
postgresTest("single-DB absent batch friendlyId returns undefined", async ({ prisma }) => {
const ctx = await seedEnv(prisma, "pt-nf");
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const presenter = new ApiBatchResultsPresenter(
prisma,
prisma,
{ splitEnabled: false },
runStore
);
const result = await presenter.call("batch_missing", env(ctx));
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,625 @@
import { containerTest, heteroPostgresTest } from "@internal/testcontainers";
import { PostgresRunStore } from "@internal/run-store";
import type { Prisma, PrismaClient } from "@trigger.dev/database";
import { generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
import { beforeEach, describe, expect, vi } from "vitest";
// `resolveSchedule` reads the module-level `prisma` (control-plane handle).
// Point `~/db.server`'s handles at a hoisted mutable holder each test sets to a
// real container client. Not a DB mock: reads still hit a real Postgres
// container — this only redirects which real client the module resolves.
const dbHolder = vi.hoisted(() => ({
prisma: null as PrismaClient | null,
$replica: null as PrismaClient | null,
}));
vi.mock("~/db.server", () => ({
get prisma() {
return dbHolder.prisma;
},
get $replica() {
return dbHolder.$replica;
},
}));
// Inert: payloads are inline JSON, never `application/store`, so no presign.
vi.mock("~/v3/objectStore.server", () => ({
generatePresignedUrl: vi.fn(async () => ({ success: false, error: "not-used" })),
}));
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import type { FoundRun } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { CURRENT_API_VERSION } from "~/api/versions";
vi.setConfig({ testTimeout: 60_000 });
// The presenter's EXACT read shape — pinned so a refactor that changes
// `findRun`'s `where`/`select` must update this test in lockstep.
const commonRunSelect = {
id: true,
friendlyId: true,
status: true,
taskIdentifier: true,
createdAt: true,
startedAt: true,
updatedAt: true,
completedAt: true,
expiredAt: true,
delayUntil: true,
metadata: true,
metadataType: true,
ttl: true,
costInCents: true,
baseCostInCents: true,
usageDurationMs: true,
idempotencyKey: true,
idempotencyKeyOptions: true,
isTest: true,
depth: true,
scheduleId: true,
workerQueue: true,
region: true,
lockedToVersionId: true,
resumeParentOnCompletion: true,
batch: { select: { id: true, friendlyId: true } },
runTags: true,
} satisfies Prisma.TaskRunSelect;
const findRunSelect = {
...commonRunSelect,
traceId: true,
payload: true,
payloadType: true,
output: true,
outputType: true,
error: true,
attempts: { select: { id: true } },
attemptNumber: true,
engine: true,
taskEventStore: true,
parentTaskRun: { select: commonRunSelect },
rootTaskRun: { select: commonRunSelect },
childRuns: { select: commonRunSelect },
} satisfies Prisma.TaskRunSelect;
// Drive the read exactly as `findRun` does: the RunStore.findRun contract over
// the given store with the presenter's where+select. The scalar `lockedToVersionId`
// folds to a resolved `lockedToVersion` per node, matching the presenter's shape;
// seeded runs carry no locked version, so every node resolves to null.
async function readFoundRunViaStore(
store: PostgresRunStore,
friendlyId: string,
runtimeEnvironmentId: string
): Promise<FoundRun | null> {
const pgRow = (await store.findRun(
{ friendlyId, runtimeEnvironmentId },
{ select: findRunSelect }
)) as Record<string, any> | null;
if (!pgRow) return null;
const foldVersion = (run: Record<string, any>) => ({ ...run, lockedToVersion: null });
return {
...pgRow,
lockedToVersion: null,
parentTaskRun: pgRow.parentTaskRun ? foldVersion(pgRow.parentTaskRun) : null,
rootTaskRun: pgRow.rootTaskRun ? foldVersion(pgRow.rootTaskRun) : null,
childRuns: (pgRow.childRuns ?? []).map(foldVersion),
isBuffered: false,
} as FoundRun;
}
async function seedOrgProjectEnv(prisma: PrismaClient, suffix: string) {
const organization = await prisma.organization.create({
data: { title: `test-${suffix}`, slug: `test-${suffix}` },
});
const project = await prisma.project.create({
data: {
name: `test-${suffix}`,
slug: `test-${suffix}`,
organizationId: organization.id,
externalRef: `test-${suffix}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `test-${suffix}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${suffix}`,
pkApiKey: `pk_dev_${suffix}`,
shortcode: `short-${suffix}`,
},
});
return { organization, project, runtimeEnvironment };
}
// Build the AuthenticatedEnvironment shape `call()` reads (id, organizationId,
// slug, project.externalRef). Only those fields are touched on the happy path.
function authEnv(
organization: { id: string },
project: { id: string; externalRef: string },
runtimeEnvironment: { id: string; slug: string }
): AuthenticatedEnvironment {
return {
id: runtimeEnvironment.id,
slug: runtimeEnvironment.slug,
organizationId: organization.id,
organization: { id: organization.id },
project: { id: project.id, externalRef: project.externalRef },
} as unknown as AuthenticatedEnvironment;
}
interface SeedRunOpts {
id: string;
friendlyId: string;
runtimeEnvironmentId: string;
projectId: string;
organizationId: string;
scheduleId?: string;
runTags?: string[];
parentTaskRunId?: string;
rootTaskRunId?: string;
metadata?: string;
}
async function seedRun(prisma: PrismaClient, opts: SeedRunOpts) {
return prisma.taskRun.create({
data: {
id: opts.id,
friendlyId: opts.friendlyId,
taskIdentifier: "my-task",
payload: JSON.stringify({ hello: "world" }),
payloadType: "application/json",
traceId: `trace_${opts.id}`,
spanId: `span_${opts.id}`,
queue: "task/my-task",
runtimeEnvironmentId: opts.runtimeEnvironmentId,
projectId: opts.projectId,
organizationId: opts.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
runTags: opts.runTags ?? [],
scheduleId: opts.scheduleId,
parentTaskRunId: opts.parentTaskRunId,
rootTaskRunId: opts.rootTaskRunId,
metadata: opts.metadata,
metadataType: opts.metadata ? "application/json" : undefined,
},
});
}
// A TaskRunAttempt requires the BackgroundWorker -> BackgroundWorkerTask ->
// TaskQueue FK chain; seed the minimum of each for one attempt.
async function seedAttempt(
prisma: PrismaClient,
opts: { runId: string; runtimeEnvironmentId: string; projectId: string; suffix: string }
) {
const worker = await prisma.backgroundWorker.create({
data: {
friendlyId: `worker_${opts.runId}`,
contentHash: `hash_${opts.suffix}`,
version: "20260627.1",
metadata: {},
projectId: opts.projectId,
runtimeEnvironmentId: opts.runtimeEnvironmentId,
},
});
const task = await prisma.backgroundWorkerTask.create({
data: {
friendlyId: `task_${opts.runId}`,
slug: "my-task",
filePath: "src/trigger/my-task.ts",
workerId: worker.id,
projectId: opts.projectId,
runtimeEnvironmentId: opts.runtimeEnvironmentId,
},
});
const queue = await prisma.taskQueue.create({
data: {
friendlyId: `queue_${opts.runId}`,
name: "task/my-task",
projectId: opts.projectId,
runtimeEnvironmentId: opts.runtimeEnvironmentId,
},
});
return prisma.taskRunAttempt.create({
data: {
friendlyId: `attempt_${opts.runId}`,
number: 1,
taskRunId: opts.runId,
runtimeEnvironmentId: opts.runtimeEnvironmentId,
backgroundWorkerId: worker.id,
backgroundWorkerTaskId: task.id,
queueId: queue.id,
status: "EXECUTING",
},
});
}
// Seed a run plus a parent, a root, a child and one attempt — the tree that must
// round-trip. `seedTestRun.ts` only seeds a single root run, so the tree + attempt
// rows are created inline here.
async function seedRunWithTree(
prisma: PrismaClient,
base: {
runtimeEnvironmentId: string;
projectId: string;
organizationId: string;
suffix: string;
}
) {
const parentId = generateKsuidId();
const rootId = generateKsuidId();
const runId = generateKsuidId();
const childId = generateKsuidId();
await seedRun(prisma, {
id: rootId,
friendlyId: `run_${rootId}`,
runtimeEnvironmentId: base.runtimeEnvironmentId,
projectId: base.projectId,
organizationId: base.organizationId,
});
await seedRun(prisma, {
id: parentId,
friendlyId: `run_${parentId}`,
runtimeEnvironmentId: base.runtimeEnvironmentId,
projectId: base.projectId,
organizationId: base.organizationId,
rootTaskRunId: rootId,
});
const run = await seedRun(prisma, {
id: runId,
friendlyId: `run_${runId}`,
runtimeEnvironmentId: base.runtimeEnvironmentId,
projectId: base.projectId,
organizationId: base.organizationId,
parentTaskRunId: parentId,
rootTaskRunId: rootId,
runTags: ["alpha", "beta"],
metadata: JSON.stringify({ k: base.suffix }),
});
await seedRun(prisma, {
id: childId,
friendlyId: `run_${childId}`,
runtimeEnvironmentId: base.runtimeEnvironmentId,
projectId: base.projectId,
organizationId: base.organizationId,
parentTaskRunId: runId,
rootTaskRunId: rootId,
});
const attempt = await seedAttempt(prisma, {
runId,
runtimeEnvironmentId: base.runtimeEnvironmentId,
projectId: base.projectId,
suffix: base.suffix,
});
return {
run,
runFriendlyId: `run_${runId}`,
parentFriendlyId: `run_${parentId}`,
rootFriendlyId: `run_${rootId}`,
childFriendlyId: `run_${childId}`,
attemptId: attempt.id,
};
}
beforeEach(() => {
dbHolder.prisma = null;
dbHolder.$replica = null;
});
describe("ApiRetrieveRunPresenter.findRun store-routed read (single-DB invariant)", () => {
containerTest(
"returns run + attempts + tree from the store read; resolveSchedule reads control-plane prisma",
async ({ prisma }) => {
// Single-DB shape: one PostgresRunStore over the one prisma/replica pair,
// exactly as the production `runStore.server.ts` singleton constructs it.
dbHolder.prisma = prisma;
dbHolder.$replica = prisma;
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
prisma,
"single"
);
// Control-plane schedule on the SAME single client.
const scheduleId = generateKsuidId();
await prisma.taskSchedule.create({
data: {
id: scheduleId,
friendlyId: `sched_${scheduleId}`,
externalId: "my-external-schedule",
taskIdentifier: "my-task",
generatorExpression: "0 * * * *",
generatorDescription: "Every hour",
projectId: project.id,
},
});
const tree = await seedRunWithTree(prisma, {
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
suffix: "single",
});
await prisma.taskRun.update({
where: { id: tree.run.id },
data: { scheduleId },
});
const env = authEnv(organization, project, runtimeEnvironment);
const found = await readFoundRunViaStore(store, tree.runFriendlyId, env.id);
expect(found).not.toBeNull();
expect(found!.isBuffered).toBe(false);
expect(found!.friendlyId).toBe(tree.runFriendlyId);
expect(found!.parentTaskRun?.friendlyId).toBe(tree.parentFriendlyId);
expect(found!.rootTaskRun?.friendlyId).toBe(tree.rootFriendlyId);
expect(found!.childRuns.map((c) => c.friendlyId)).toEqual([tree.childFriendlyId]);
expect(found!.attempts.map((a) => a.id)).toEqual([tree.attemptId]);
expect([...found!.runTags].sort()).toEqual(["alpha", "beta"]);
// Drive the full presenter `call()` — exercises the real control-plane
// `resolveSchedule` against the module `prisma` (the container client).
const out = await new ApiRetrieveRunPresenter(CURRENT_API_VERSION).call(found!, env);
expect(out.schedule).toBeDefined();
expect(out.schedule?.externalId).toBe("my-external-schedule");
expect(out.schedule?.generator.expression).toBe("0 * * * *");
expect(out.relatedRuns.parent?.id).toBe(tree.parentFriendlyId);
expect(out.relatedRuns.root?.id).toBe(tree.rootFriendlyId);
expect(out.relatedRuns.children.map((c) => c.id)).toEqual([tree.childFriendlyId]);
expect(out.attemptCount).toBe(found!.attemptNumber ?? 0);
}
);
containerTest(
"resolveSchedule re-reads TaskSchedule off the control-plane prisma on every call (no caching)",
async ({ prisma }) => {
// Single-DB: this proves resolveSchedule re-reads `prisma.taskSchedule`
// on each call() and reflects a delete (no stale cache). The structural
// "schedule comes from the control-plane client, not the run-ops store"
// separation is proven by the cross-DB test below (distinct schedule
// client + an onLegacy=null check); in single-DB both views are the
// same physical row, so this case cannot discriminate the two paths.
dbHolder.prisma = prisma;
dbHolder.$replica = prisma;
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
prisma,
"cp-inv"
);
const scheduleId = generateKsuidId();
await prisma.taskSchedule.create({
data: {
id: scheduleId,
friendlyId: `sched_${scheduleId}`,
taskIdentifier: "my-task",
generatorExpression: "*/5 * * * *",
projectId: project.id,
},
});
const runId = generateKsuidId();
await seedRun(prisma, {
id: runId,
friendlyId: `run_${runId}`,
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
scheduleId,
});
const env = authEnv(organization, project, runtimeEnvironment);
const found = await readFoundRunViaStore(store, `run_${runId}`, env.id);
expect(found).not.toBeNull();
expect(found!.scheduleId).toBe(scheduleId);
const presenter = new ApiRetrieveRunPresenter(CURRENT_API_VERSION);
const out = await presenter.call(found!, env);
expect(out.schedule?.id).toBe(`sched_${scheduleId}`);
// Delete the control-plane TaskSchedule row; the run-ops row is
// untouched. resolveSchedule must now return undefined — proving the
// schedule comes from `prisma.taskSchedule`, NOT from the run-ops store.
await prisma.taskSchedule.delete({ where: { id: scheduleId } });
const out2 = await presenter.call(found!, env);
expect(out2.schedule).toBeUndefined();
}
);
});
describe("ApiRetrieveRunPresenter.findRun cross-version read (PG14 + PG17)", () => {
heteroPostgresTest(
"single retrieve returns run + attempts + tree byte-identically from NEW (PG17) and LEGACY (PG14) stores",
async ({ prisma17, prisma14 }) => {
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
// NEW residency subgraph on PG17.
const newEnv = await seedOrgProjectEnv(prisma17, "new");
const newTree = await seedRunWithTree(prisma17, {
runtimeEnvironmentId: newEnv.runtimeEnvironment.id,
projectId: newEnv.project.id,
organizationId: newEnv.organization.id,
suffix: "new",
});
// Distinct LEGACY residency subgraph on PG14.
const legacyEnv = await seedOrgProjectEnv(prisma14, "legacy");
const legacyTree = await seedRunWithTree(prisma14, {
runtimeEnvironmentId: legacyEnv.runtimeEnvironment.id,
projectId: legacyEnv.project.id,
organizationId: legacyEnv.organization.id,
suffix: "legacy",
});
// Read the NEW run from the PG17 store.
const foundNew = await readFoundRunViaStore(
newStore,
newTree.runFriendlyId,
newEnv.runtimeEnvironment.id
);
expect(foundNew).not.toBeNull();
expect(foundNew!.friendlyId).toBe(newTree.runFriendlyId);
expect(foundNew!.parentTaskRun?.friendlyId).toBe(newTree.parentFriendlyId);
expect(foundNew!.rootTaskRun?.friendlyId).toBe(newTree.rootFriendlyId);
expect(foundNew!.childRuns.map((c) => c.friendlyId)).toEqual([newTree.childFriendlyId]);
expect(foundNew!.attempts.map((a) => a.id)).toEqual([newTree.attemptId]);
expect([...foundNew!.runTags].sort()).toEqual(["alpha", "beta"]);
expect(foundNew!.metadata).toBe(JSON.stringify({ k: "new" }));
// Sanity: the two stores are genuinely distinct DBs — the NEW run's key
// is absent from PG14 (it was only ever written to PG17).
const leaked = await readFoundRunViaStore(
legacyStore,
newTree.runFriendlyId,
newEnv.runtimeEnvironment.id
);
expect(leaked).toBeNull();
// Read the LEGACY run from the PG14 store — byte-identical tree.
const foundLegacy = await readFoundRunViaStore(
legacyStore,
legacyTree.runFriendlyId,
legacyEnv.runtimeEnvironment.id
);
expect(foundLegacy).not.toBeNull();
expect(foundLegacy!.friendlyId).toBe(legacyTree.runFriendlyId);
expect(foundLegacy!.parentTaskRun?.friendlyId).toBe(legacyTree.parentFriendlyId);
expect(foundLegacy!.rootTaskRun?.friendlyId).toBe(legacyTree.rootFriendlyId);
expect(foundLegacy!.childRuns.map((c) => c.friendlyId)).toEqual([legacyTree.childFriendlyId]);
expect(foundLegacy!.attempts.map((a) => a.id)).toEqual([legacyTree.attemptId]);
expect(foundLegacy!.metadata).toBe(JSON.stringify({ k: "legacy" }));
}
);
heteroPostgresTest(
"schedule resolved cross-DB: run hydrated from run-ops store (PG14), schedule from a distinct control-plane client (PG17)",
async ({ prisma17, prisma14 }) => {
// Run-ops residency = LEGACY (PG14). Control-plane (TaskSchedule) lives
// on a DISTINCT client — PG17 — and is the handle the module `prisma`
// resolves to in this test. This proves the run-ops-row -> control-plane
// -schedule cross-seam read: the run comes from one DB, the schedule
// from another.
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
// Module control-plane handle -> PG17.
dbHolder.prisma = prisma17;
dbHolder.$replica = prisma17;
const legacyEnv = await seedOrgProjectEnv(prisma14, "x-run");
// The control-plane project the schedule hangs off lives on PG17.
const cpEnv = await seedOrgProjectEnv(prisma17, "x-cp");
const scheduleId = generateKsuidId();
await prisma17.taskSchedule.create({
data: {
id: scheduleId,
friendlyId: `sched_${scheduleId}`,
externalId: "cross-db-schedule",
taskIdentifier: "my-task",
generatorExpression: "0 0 * * *",
projectId: cpEnv.project.id,
},
});
const runId = generateKsuidId();
await seedRun(prisma14, {
id: runId,
friendlyId: `run_${runId}`,
runtimeEnvironmentId: legacyEnv.runtimeEnvironment.id,
projectId: legacyEnv.project.id,
organizationId: legacyEnv.organization.id,
scheduleId,
});
const env = authEnv(legacyEnv.organization, legacyEnv.project, legacyEnv.runtimeEnvironment);
const found = await readFoundRunViaStore(
legacyStore,
`run_${runId}`,
legacyEnv.runtimeEnvironment.id
);
expect(found).not.toBeNull();
expect(found!.scheduleId).toBe(scheduleId);
// The schedule row does NOT exist on the run-ops (PG14) client.
const onLegacy = await prisma14.taskSchedule.findFirst({ where: { id: scheduleId } });
expect(onLegacy).toBeNull();
const out = await new ApiRetrieveRunPresenter(CURRENT_API_VERSION).call(found!, env);
expect(out.schedule).toBeDefined();
expect(out.schedule?.id).toBe(`sched_${scheduleId}`);
expect(out.schedule?.externalId).toBe("cross-db-schedule");
expect(out.schedule?.generator.expression).toBe("0 0 * * *");
}
);
heteroPostgresTest(
"correct past-retention / not-found response: both stores miss => findRun returns null",
async ({ prisma17, prisma14 }) => {
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
const newEnv = await seedOrgProjectEnv(prisma17, "miss-new");
const legacyEnv = await seedOrgProjectEnv(prisma14, "miss-legacy");
// A run that exists on NEITHER store (terminated + past-retention,
// observed at this layer as a miss on both underlying stores).
const goneFriendlyId = `run_${generateKsuidId()}`;
const fromNew = await readFoundRunViaStore(
newStore,
goneFriendlyId,
newEnv.runtimeEnvironment.id
);
const fromLegacy = await readFoundRunViaStore(
legacyStore,
goneFriendlyId,
legacyEnv.runtimeEnvironment.id
);
expect(fromNew).toBeNull();
expect(fromLegacy).toBeNull();
}
);
heteroPostgresTest(
"single-DB passthrough: one PostgresRunStore over one client hydrates run + tree (self-host collapse)",
async ({ prisma17 }) => {
// The single-DB collapse: one plain PostgresRunStore over one client.
// No legacy probe / known-migrated machinery at this layer.
const store = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
const onlyEnv = await seedOrgProjectEnv(prisma17, "passthrough");
const tree = await seedRunWithTree(prisma17, {
runtimeEnvironmentId: onlyEnv.runtimeEnvironment.id,
projectId: onlyEnv.project.id,
organizationId: onlyEnv.organization.id,
suffix: "passthrough",
});
const found = await readFoundRunViaStore(
store,
tree.runFriendlyId,
onlyEnv.runtimeEnvironment.id
);
expect(found).not.toBeNull();
expect(found!.isBuffered).toBe(false);
expect(found!.parentTaskRun?.friendlyId).toBe(tree.parentFriendlyId);
expect(found!.rootTaskRun?.friendlyId).toBe(tree.rootFriendlyId);
expect(found!.childRuns.map((c) => c.friendlyId)).toEqual([tree.childFriendlyId]);
expect(found!.attempts.map((a) => a.id)).toEqual([tree.attemptId]);
}
);
});
@@ -0,0 +1,411 @@
import { describe, expect, vi } from "vitest";
// The presenter graph imports `~/v3/runStore.server` (via RunsRepository) which imports
// `~/db.server` at load, and the presenter itself reaches `~/db.server`'s `$replica` singleton
// through `findDisplayableEnvironment` and `getTaskIdentifiers`. Stub the module so those
// singleton reads resolve. This is the ONLY mock — the DB is NEVER mocked; the proxy delegates
// to the per-test REAL legacy (PG14) container so the env-lookup + task-identifier reads hit a
// real database. Everything asserted runs against real containers. Mirrors
// nextRunListPresenter.readthrough.test.ts.
const legacyReplicaHolder = vi.hoisted(() => ({ client: undefined as any }));
const newClientHolder = vi.hoisted(() => ({ client: undefined as any }));
// `ApiRunListPresenter` resolves its read ClickHouse internally via the `clickhouseFactory`
// singleton (which imports `~/env.server` and binds to a process-wide default client). Stub the
// instance module so `getClickhouseForOrganization` returns the per-test container's ClickHouse
// handle (set by each test before calling). This is a module-resolution shim — the ClickHouse is
// a REAL testcontainer, never mocked — mirroring the `~/db.server` stub below.
const clickhouseHolder = vi.hoisted(() => ({ client: undefined as any }));
vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
clickhouseFactory: {
getClickhouseForOrganization: async () => {
if (!clickhouseHolder.client) {
throw new Error("clickhouseHolder.client not set for this test");
}
return clickhouseHolder.client;
},
},
}));
vi.mock("~/db.server", async () => {
const { Prisma } = await import("@trigger.dev/database");
const lazyProxy = (holder: { client: any }, label: string) =>
new Proxy(
{},
{
get(_t, prop) {
if (!holder.client) {
throw new Error(`${label} not set for this test`);
}
return holder.client[prop];
},
}
);
const replicaProxy = lazyProxy(legacyReplicaHolder, "legacyReplicaHolder.client");
const newProxy = lazyProxy(newClientHolder, "newClientHolder.client");
return {
prisma: replicaProxy,
$replica: replicaProxy,
runOpsNewPrisma: newProxy,
runOpsNewReplica: newProxy,
runOpsLegacyPrisma: replicaProxy,
runOpsLegacyReplica: replicaProxy,
sqlDatabaseSchema: Prisma.sql([`public`]),
};
});
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
import { PrismaClient } from "@trigger.dev/database";
import { setTimeout } from "node:timers/promises";
import { CURRENT_API_VERSION } from "~/api/versions";
import { ApiRunListPresenter } from "~/presenters/v3/ApiRunListPresenter.server";
import { setupClickhouseReplication } from "./utils/replicationUtils";
vi.setConfig({ testTimeout: 90_000 });
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
environmentSlug: string;
};
/**
* Creates the org/project/env parents on a single prisma client. TaskRun FKs require these to
* exist on every DB a run lives on, so identical parents (same ids) are seeded on both the
* legacy (PG14) and new (PG17) databases.
*/
async function seedParents(
prisma: PrismaClient,
slug: string,
envSlug = `env-${slug}`
): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: envSlug,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${slug}`,
pkApiKey: `pk_dev_${slug}`,
shortcode: `sc-${slug}`,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: runtimeEnvironment.id,
environmentSlug: runtimeEnvironment.slug,
};
}
/** Adds an extra RuntimeEnvironment (control-plane row) to an existing project. */
async function addEnvironment(
prisma: PrismaClient,
ctx: SeedContext,
slug: string,
envSlug: string
): Promise<string> {
const env = await prisma.runtimeEnvironment.create({
data: {
slug: envSlug,
type: "STAGING",
projectId: ctx.projectId,
organizationId: ctx.organizationId,
apiKey: `tr_${envSlug}_${slug}`,
pkApiKey: `pk_${envSlug}_${slug}`,
shortcode: `sc-${envSlug}-${slug}`,
},
});
return env.id;
}
/** Mirrors the org/project/env parents onto a second DB with the SAME ids. */
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
await prisma.organization.create({
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: ctx.projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: ctx.organizationId,
externalRef: `proj-${slug}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environmentId,
slug: ctx.environmentSlug,
type: "DEVELOPMENT",
projectId: ctx.projectId,
organizationId: ctx.organizationId,
apiKey: `tr_dev_${slug}_b`,
pkApiKey: `pk_dev_${slug}_b`,
shortcode: `sc-${slug}-b`,
},
});
}
async function createRun(
prisma: PrismaClient,
ctx: SeedContext,
run: {
friendlyId: string;
taskIdentifier?: string;
status?: any;
runtimeEnvironmentId?: string;
}
) {
return prisma.taskRun.create({
data: {
friendlyId: run.friendlyId,
taskIdentifier: run.taskIdentifier ?? "my-task",
status: run.status ?? "PENDING",
payload: JSON.stringify({ foo: run.friendlyId }),
traceId: run.friendlyId,
spanId: run.friendlyId,
queue: "test",
runTags: [],
runtimeEnvironmentId: run.runtimeEnvironmentId ?? ctx.environmentId,
projectId: ctx.projectId,
organizationId: ctx.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
}
describe("ApiRunListPresenter public /runs list (PG14 legacy + PG17 new)", () => {
// Public list serves run-ops rows through the routed store. The
// forwarded readThroughDeps thread the dual-DB union into NextRunListPresenter; the public
// payload (`{ data, pagination }`) must list the NEW legacy union, proving the public API
// surfaces routed run-ops rows. The migrated/straggler rows (run_newA/run_newB) live on BOTH
// DBs with the same id + friendlyId but a DISTINGUISHING taskIdentifier ("my-task-NEW" on PG17),
// so a row served from the threaded newClient is identifiable in the public payload.
replicationContainerTest(
"public payload lists run-ops rows served via the routed store (NEW + legacy union)",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
legacyReplicaHolder.client = prisma;
clickhouseHolder.client = clickhouse;
// The routed store's default known-migrated probe reads `runOpsNewPrisma` -> PG17.
newClientHolder.client = prismaNew;
try {
const ctx = await seedParents(prisma, "hydrate");
await mirrorParents(prismaNew, ctx, "hydrate");
// All four runs land on PG14 (legacy + replication source -> CH gets the full id-set).
const legacyOnlyA = await createRun(prisma, ctx, { friendlyId: "run_legacyA" });
const legacyOnlyB = await createRun(prisma, ctx, { friendlyId: "run_legacyB" });
const migratedA = await createRun(prisma, ctx, { friendlyId: "run_newA" });
const migratedB = await createRun(prisma, ctx, { friendlyId: "run_newB" });
// The two "migrated" runs also live on NEW (authoritative during retention), same ids +
// friendlyIds, but a DISTINGUISHING taskIdentifier so a row served from PG17 is
// identifiable in the public payload.
await createRun(prismaNew, ctx, { friendlyId: "run_newA", taskIdentifier: "my-task-NEW" });
await createRun(prismaNew, ctx, { friendlyId: "run_newB", taskIdentifier: "my-task-NEW" });
await prismaNew.taskRun.update({
where: { friendlyId: "run_newA" },
data: { id: migratedA.id },
});
await prismaNew.taskRun.update({
where: { friendlyId: "run_newB" },
data: { id: migratedB.id },
});
// Wait for CH replication so the id-set page is non-empty.
await setTimeout(1500);
const presenter = new ApiRunListPresenter(prisma, prisma, {
newClient: prismaNew,
legacyReplica: prisma,
splitEnabled: true,
});
const result = await presenter.call(
{ id: ctx.projectId },
{ "page[size]": 10 } as any,
CURRENT_API_VERSION,
{ id: ctx.environmentId, organizationId: ctx.organizationId }
);
// The public payload lists runs by `id` = `run.friendlyId`, id-desc ordered.
const expectedFriendlyIds = [
{ id: migratedA.id, friendlyId: "run_newA" },
{ id: migratedB.id, friendlyId: "run_newB" },
{ id: legacyOnlyA.id, friendlyId: "run_legacyA" },
{ id: legacyOnlyB.id, friendlyId: "run_legacyB" },
]
.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
.map((r) => r.friendlyId);
expect(result.data.map((r) => r.id)).toEqual(expectedFriendlyIds);
// The migrated rows must carry the PG17-only taskIdentifier — only possible if the public
// path hydrated them through the threaded newClient (PG17). taskKind falls back to STANDARD.
const migratedRow = result.data.find((r) => r.id === "run_newA");
expect(migratedRow?.taskIdentifier).toBe("my-task-NEW");
expect(migratedRow?.taskKind).toBe("STANDARD");
// The legacy-only rows surface from PG14, proving the legacyReplica is also exercised.
expect(result.data.find((r) => r.id === "run_legacyA")?.taskIdentifier).toBe("my-task");
// Pagination shape is present.
expect(result.pagination).toHaveProperty("next");
expect(result.pagination).toHaveProperty("previous");
} finally {
await prismaNew.$disconnect();
}
}
);
// Genuinely-empty env returns { data: [], pagination } without error. Exercises the
// empty-state probe beneath NextRunListPresenter (no rows on either DB; empty CH page).
replicationContainerTest(
"genuinely-empty env returns { data: [], pagination } without error",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
legacyReplicaHolder.client = prisma;
clickhouseHolder.client = clickhouse;
try {
const ctx = await seedParents(prisma, "empty");
await mirrorParents(prismaNew, ctx, "empty");
const presenter = new ApiRunListPresenter(prisma, prisma, {
newClient: prismaNew,
legacyReplica: prisma,
splitEnabled: true,
});
const result = await presenter.call(
{ id: ctx.projectId },
{ "page[size]": 10 } as any,
CURRENT_API_VERSION,
{ id: ctx.environmentId, organizationId: ctx.organizationId }
);
expect(result.data).toEqual([]);
expect(result.pagination).toHaveProperty("next");
expect(result.pagination).toHaveProperty("previous");
} finally {
await prismaNew.$disconnect();
}
}
);
// Env scoping unchanged: the control-plane runtimeEnvironment.findMany lookup
// resolves the requested env via the `_replica` handle (NOT routed), with the 4th `environment`
// arg omitted to force that branch. Result is scoped to the requested env only.
replicationContainerTest(
"env scoping resolves via the control-plane _replica handle (filter[env], 4th arg omitted)",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
legacyReplicaHolder.client = prisma;
clickhouseHolder.client = clickhouse;
const ctx = await seedParents(prisma, "scoping", "prod");
const stagingEnvId = await addEnvironment(prisma, ctx, "scoping", "staging");
// Runs in prod only; a run in staging must NOT surface when filter[env]=prod.
await createRun(prisma, ctx, { friendlyId: "run_prod1" });
await createRun(prisma, ctx, { friendlyId: "run_prod2" });
await createRun(prisma, ctx, {
friendlyId: "run_staging",
runtimeEnvironmentId: stagingEnvId,
});
await setTimeout(1500);
// Single-handle passthrough; the env lookup runs on `_replica` (= prisma) via findMany.
const presenter = new ApiRunListPresenter(prisma, prisma);
// 4th `environment` arg OMITTED -> forces the runtimeEnvironment.findMany branch.
const result = await presenter.call(
{ id: ctx.projectId },
{ "page[size]": 10, "filter[env]": ["prod"] } as any,
CURRENT_API_VERSION
);
// Scoped to the resolved prod env only.
expect(result.data.map((r) => r.id).sort()).toEqual(["run_prod1", "run_prod2"]);
}
);
// Passthrough (single-DB): two-arg-style construction (no readThroughDeps) ->
// NextRunListPresenter receives undefined deps -> byte-identical single-DB path. The public
// { data, pagination } shape is unchanged.
replicationContainerTest(
"single-DB passthrough: no readThroughDeps lists the seeded runs unchanged",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
legacyReplicaHolder.client = prisma;
clickhouseHolder.client = clickhouse;
const ctx = await seedParents(prisma, "passthrough");
await createRun(prisma, ctx, { friendlyId: "run_pt1" });
await createRun(prisma, ctx, { friendlyId: "run_pt2" });
await setTimeout(1500);
// No readThroughDeps -> passthrough, exactly as the routes construct it today.
const presenter = new ApiRunListPresenter(prisma, prisma);
const result = await presenter.call(
{ id: ctx.projectId },
{ "page[size]": 10 } as any,
CURRENT_API_VERSION,
{ id: ctx.environmentId, organizationId: ctx.organizationId }
);
expect(result.data.map((r) => r.id).sort()).toEqual(["run_pt1", "run_pt2"]);
expect(result).toHaveProperty("pagination");
expect(result.pagination).toHaveProperty("next");
expect(result.pagination).toHaveProperty("previous");
}
);
});
@@ -0,0 +1,387 @@
// Read-through proof for the public single-run result poll (ApiRunResultPresenter). The presenter
// routes its TaskRun(+attempts) lookup-by-friendlyId through readThroughRun: split mode resolves
// from new first then the legacy READ REPLICA on a new-probe miss (never a primary),
// past-retention → undefined → the route's normal 404; single-DB is one plain findFirst. NEVER mock
// the DB — the cross-version proof uses a heterogeneous legacy+new Postgres fixture; only pure
// boundaries (splitEnabled/isPastRetention) are injected.
import { heteroPostgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { customAlphabet } from "nanoid";
import { describe, expect, vi } from "vitest";
import { ApiRunResultPresenter } from "~/presenters/v3/ApiRunResultPresenter.server";
import type { PrismaReplicaClient } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
// Neutralize the db.server singleton so importing the presenter (via BasePresenter) and
// readThrough.server (which imports db.server defaults) does not try to connect to the env
// database. Every read in this file goes through clients we inject explicitly.
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
vi.setConfig({ testTimeout: 60_000 });
const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
// Residency by friendlyId length (after stripping `run_`): 27-char body → NEW (ksuid analog),
// 25-char body → LEGACY (cuid analog). ownerEngine classifies on the public friendly id.
function newFriendlyId(): string {
return "run_" + customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 27)();
}
function legacyFriendlyId(): string {
return "run_" + customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 25)();
}
function authEnv(environmentId: string): AuthenticatedEnvironment {
// The presenter only reads env.id (the runtimeEnvironmentId filter) and the tracing attrs.
return {
id: environmentId,
project: { id: "p", name: "p" },
organization: { id: "o", title: "o" },
orgMember: null,
} as unknown as AuthenticatedEnvironment;
}
type SeedContext = {
environmentId: string;
projectId: string;
organizationId: string;
backgroundWorkerId: string;
backgroundWorkerTaskId: string;
queueId: string;
};
async function seedEnv(prisma: PrismaClient, slug: string) {
const user = await prisma.user.create({
data: { email: `${slug}@test.com`, name: "t", authenticationMethod: "MAGIC_LINK" },
});
const organization = await prisma.organization.create({
data: {
title: "Org",
slug: `org-${slug}-${idGenerator()}`,
members: { create: { userId: user.id, role: "ADMIN" } },
},
});
const project = await prisma.project.create({
data: {
name: "Proj",
slug: `proj-${slug}-${idGenerator()}`,
organizationId: organization.id,
externalRef: `ext-${slug}-${idGenerator()}`,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `api-${slug}-${idGenerator()}`,
pkApiKey: `pk-${slug}-${idGenerator()}`,
shortcode: `sc-${slug}-${idGenerator()}`,
},
});
return { organization, project, environment };
}
async function seedWorker(prisma: PrismaClient, ctx: { environmentId: string; projectId: string }) {
const queue = await prisma.taskQueue.create({
data: {
friendlyId: `queue_${idGenerator()}`,
name: "task/test-task",
projectId: ctx.projectId,
runtimeEnvironmentId: ctx.environmentId,
},
});
const worker = await prisma.backgroundWorker.create({
data: {
friendlyId: `worker_${idGenerator()}`,
contentHash: "hash",
projectId: ctx.projectId,
runtimeEnvironmentId: ctx.environmentId,
version: "20240101.1",
metadata: {},
},
});
const task = await prisma.backgroundWorkerTask.create({
data: {
friendlyId: `task_${idGenerator()}`,
slug: "test-task",
filePath: "src/test.ts",
exportName: "testTask",
workerId: worker.id,
projectId: ctx.projectId,
runtimeEnvironmentId: ctx.environmentId,
},
});
return { queueId: queue.id, backgroundWorkerId: worker.id, backgroundWorkerTaskId: task.id };
}
async function fullSeed(prisma: PrismaClient, slug: string): Promise<SeedContext> {
const { organization, project, environment } = await seedEnv(prisma, slug);
const worker = await seedWorker(prisma, {
environmentId: environment.id,
projectId: project.id,
});
return {
environmentId: environment.id,
projectId: project.id,
organizationId: organization.id,
...worker,
};
}
async function seedRunWithAttempt(
prisma: PrismaClient,
ctx: SeedContext,
friendlyId: string,
opts: {
status: "COMPLETED_SUCCESSFULLY" | "COMPLETED_WITH_ERRORS" | "CANCELED" | "EXECUTING";
attempt?: {
status: "COMPLETED" | "FAILED";
output?: string;
outputType?: string;
error?: unknown;
};
}
) {
const runInternalId = idGenerator();
const run = await prisma.taskRun.create({
data: {
id: runInternalId,
friendlyId,
taskIdentifier: "test-task",
payload: "{}",
payloadType: "application/json",
traceId: idGenerator(),
spanId: idGenerator(),
queue: "task/test-task",
runtimeEnvironmentId: ctx.environmentId,
projectId: ctx.projectId,
status: opts.status,
},
});
if (opts.attempt) {
await prisma.taskRunAttempt.create({
data: {
friendlyId: `attempt_${idGenerator()}`,
taskRunId: run.id,
backgroundWorkerId: ctx.backgroundWorkerId,
backgroundWorkerTaskId: ctx.backgroundWorkerTaskId,
runtimeEnvironmentId: ctx.environmentId,
queueId: ctx.queueId,
status: opts.attempt.status,
output: opts.attempt.output,
outputType: opts.attempt.outputType ?? "application/json",
error: opts.attempt.error as any,
},
});
}
return run;
}
// A legacy-replica closure that explodes if ever touched — used to prove the primary/legacy store
// is structurally unreachable when it must not be read.
function throwingLegacy(): PrismaReplicaClient {
return new Proxy(
{},
{
get() {
throw new Error("legacy replica must never be read in this case");
},
}
) as unknown as PrismaReplicaClient;
}
describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgres)", () => {
heteroPostgresTest(
"split: a run living on the NEW DB resolves from new and never probes the legacy replica",
async ({ prisma14, prisma17 }) => {
const friendlyId = newFriendlyId();
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "new-only");
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, friendlyId, {
status: "COMPLETED_SUCCESSFULLY",
attempt: { status: "COMPLETED", output: '"hello"', outputType: "application/json" },
});
const presenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma17 as unknown as PrismaReplicaClient,
{
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: throwingLegacy(),
}
);
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
expect(result).toBeDefined();
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.id).toBe(friendlyId);
expect(result.taskIdentifier).toBe("test-task");
expect(result.output).toBe('"hello"');
expect(result.outputType).toBe("application/json");
}
}
);
// Old legacy-only run resolves from the legacy read replica (cross-version). The only legacy
// handle exposed is a read replica — no writer/primary field exists in this path.
heteroPostgresTest(
"split: an OLD legacy-only run resolves from the legacy read replica across the version boundary",
async ({ prisma14, prisma17 }) => {
const friendlyId = legacyFriendlyId();
// Seed only on legacy. New gets just an env so the new-probe runs but misses.
const legacyCtx = await fullSeed(prisma14 as unknown as PrismaClient, "legacy-only");
await fullSeed(prisma17 as unknown as PrismaClient, "new-empty");
await seedRunWithAttempt(prisma14 as unknown as PrismaClient, legacyCtx, friendlyId, {
status: "COMPLETED_SUCCESSFULLY",
attempt: { status: "COMPLETED", output: '"from-legacy"', outputType: "application/json" },
});
const presenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma14 as unknown as PrismaReplicaClient,
{
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
}
);
const result = await presenter.call(friendlyId, authEnv(legacyCtx.environmentId));
expect(result).toBeDefined();
expect(result?.ok).toBe(true);
if (result?.ok) {
// friendlyId / taskIdentifier / output+outputType round-trip across the version boundary identically.
expect(result.id).toBe(friendlyId);
expect(result.taskIdentifier).toBe("test-task");
expect(result.output).toBe('"from-legacy"');
expect(result.outputType).toBe("application/json");
}
}
);
// Legacy-classified id present on neither store, isPastRetention=true → past-retention → undefined.
heteroPostgresTest(
"split: a past-retention id returns undefined (the route's normal 404 surface)",
async ({ prisma14, prisma17 }) => {
const friendlyId = legacyFriendlyId();
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "past-ret-new");
await fullSeed(prisma14 as unknown as PrismaClient, "past-ret-legacy");
const presenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma14 as unknown as PrismaReplicaClient,
{
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
isPastRetention: () => true,
}
);
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
// Identical surface to a genuinely missing run: the route maps undefined → normal 404.
expect(result).toBeUndefined();
}
);
heteroPostgresTest(
"single-DB passthrough: resolves from the one client; the legacy replica is never touched",
async ({ prisma14, prisma17 }) => {
const friendlyId = newFriendlyId();
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "passthrough");
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, friendlyId, {
status: "COMPLETED_SUCCESSFULLY",
attempt: { status: "COMPLETED", output: '"single"', outputType: "application/json" },
});
// No read-through deps → passthrough (single plain findFirst).
const presenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma17 as unknown as PrismaReplicaClient
);
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.id).toBe(friendlyId);
expect(result.output).toBe('"single"');
}
// splitEnabled:false with a throwing legacy proves no second store is touched.
const presenter2 = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma17 as unknown as PrismaReplicaClient,
{
splitEnabled: false,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: throwingLegacy(),
}
);
const result2 = await presenter2.call(friendlyId, authEnv(ctx.environmentId));
expect(result2?.ok).toBe(true);
}
);
// executionResultForTaskRun mapping is identical across split and single-DB for every status.
heteroPostgresTest(
"status parity: success / failed / canceled map identically in split and single-DB",
async ({ prisma14, prisma17 }) => {
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "parity");
const successId = newFriendlyId();
const failedId = newFriendlyId();
const canceledId = newFriendlyId();
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, successId, {
status: "COMPLETED_SUCCESSFULLY",
attempt: { status: "COMPLETED", output: '"ok"', outputType: "application/json" },
});
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, failedId, {
status: "COMPLETED_WITH_ERRORS",
attempt: {
status: "FAILED",
error: { type: "BUILT_IN_ERROR", name: "Error", message: "boom", stackTrace: "boom" },
},
});
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, canceledId, {
status: "CANCELED",
});
const splitPresenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma17 as unknown as PrismaReplicaClient,
{
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: throwingLegacy(),
}
);
const passthroughPresenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma17 as unknown as PrismaReplicaClient
);
for (const id of [successId, failedId, canceledId]) {
const split = await splitPresenter.call(id, authEnv(ctx.environmentId));
const single = await passthroughPresenter.call(id, authEnv(ctx.environmentId));
expect(split).toEqual(single);
}
const success = await splitPresenter.call(successId, authEnv(ctx.environmentId));
expect(success?.ok).toBe(true);
const failed = await splitPresenter.call(failedId, authEnv(ctx.environmentId));
expect(failed?.ok).toBe(false);
const canceled = await splitPresenter.call(canceledId, authEnv(ctx.environmentId));
expect(canceled?.ok).toBe(false);
if (canceled && !canceled.ok) {
expect(canceled.error.type).toBe("INTERNAL_ERROR");
}
}
);
});
@@ -0,0 +1,116 @@
import { describe, expect, vi } from "vitest";
var dbClientHolder: any = undefined;
function setDbClient(client: any) {
dbClientHolder = client;
}
vi.mock("~/v3/services/baseService.server", async () => {
const { ServiceValidationError } = await import("~/v3/services/common.server");
return { ServiceValidationError };
});
vi.mock("~/db.server", async () => {
const { Prisma } = await import("@trigger.dev/database");
return {
Prisma,
sqlDatabaseSchema: Prisma.sql(["public"]),
get prisma() {
return dbClientHolder;
},
get $replica() {
return dbClientHolder;
},
};
});
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { ApiWaitpointListPresenter } from "~/presenters/v3/ApiWaitpointListPresenter.server";
vi.setConfig({ testTimeout: 120_000 });
const ENV_ID = "env0000000000000000t19";
const PROJ_ID = "proj00000000000000t19";
async function seedLegacyParents(prisma: PrismaClient, slug: string) {
const org = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: PROJ_ID,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: org.id,
externalRef: `proj-${slug}`,
engine: "V2",
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ENV_ID,
slug: `env-${slug}`,
type: "PRODUCTION",
projectId: PROJ_ID,
organizationId: org.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `sc-${slug}`,
},
});
}
const baseEnv = {
id: ENV_ID,
type: "PRODUCTION" as const,
project: { id: PROJ_ID, engine: "V2" as const },
apiKey: "tr_prod_t19",
};
describe("ApiWaitpointListPresenter read-route threading", () => {
postgresTest(
"split enabled: waitpoint on NEW handle is returned via readRoute",
async ({ prisma }) => {
setDbClient(prisma);
await seedLegacyParents(prisma, "t19split");
await prisma.waitpoint.create({
data: {
id: "wp_t19_0000000000000000001",
friendlyId: "wpt_t19_001",
type: "MANUAL",
status: "PENDING",
idempotencyKey: "idem-t19-001",
userProvidedIdempotencyKey: false,
projectId: PROJ_ID,
environmentId: ENV_ID,
},
});
const presenter = new ApiWaitpointListPresenter(undefined, undefined, {
runOpsNew: prisma as any,
runOpsLegacyReplica: prisma as any,
splitEnabled: true,
});
const result = await presenter.call(baseEnv, {});
expect(result.data.length).toBeGreaterThan(0);
expect(result.data.some((t) => t.id === "wpt_t19_001")).toBe(true);
}
);
postgresTest(
"passthrough: no readRoute => _replica only, result empty (nothing seeded)",
async ({ prisma }) => {
setDbClient(prisma);
await seedLegacyParents(prisma, "t19pass");
const presenter = new ApiWaitpointListPresenter(prisma, prisma);
const result = await presenter.call(baseEnv, {});
expect(result.data).toEqual([]);
}
);
});
@@ -0,0 +1,333 @@
// Real heterogeneous legacy + new Postgres proof for the public waitpoint retrieve read.
// The DB is never mocked: reads hit the two real containers. Only pure boundaries
// (splitEnabled, isPastRetention) and recording client wrappers are
// injected. heteroPostgresTest runs the legacy and new databases on different major versions.
import {
heteroPostgresTest,
heteroRunOpsPostgresTest,
postgresTest,
} from "@internal/testcontainers";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import type { PrismaClient, WaitpointType } from "@trigger.dev/database";
import { generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
import { describe, expect, vi } from "vitest";
import type { PrismaReplicaClient } from "~/db.server";
import { ApiWaitpointPresenter } from "~/presenters/v3/ApiWaitpointPresenter.server";
vi.setConfig({ testTimeout: 60_000 });
// 25-char cuid body (length-disjoint from the 27-char KSUID) → LEGACY residency.
function generateLegacyCuid() {
const suffix = Array.from(
{ length: 24 },
() => "0123456789abcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 36)]
).join("");
return `c${suffix}`;
}
// A read client whose waitpoint.findFirst is recorded; throws if used after being marked
// forbidden, so we can prove a store was NEVER read.
function recording(client: PrismaClient | RunOpsPrismaClient, opts: { forbidden?: boolean } = {}) {
const calls: unknown[] = [];
const waitpoint = {
findFirst: (args: unknown) => {
calls.push(args);
if (opts.forbidden) {
throw new Error("this store must never be read");
}
return (client as unknown as PrismaReplicaClient).waitpoint.findFirst(args as never);
},
};
return { handle: { ...client, waitpoint } as unknown as PrismaReplicaClient, calls };
}
async function seedOrgProjectEnv(prisma: PrismaClient, suffix: string) {
const organization = await prisma.organization.create({
data: { title: `test-${suffix}`, slug: `test-${suffix}` },
});
const project = await prisma.project.create({
data: {
name: `test-${suffix}`,
slug: `test-${suffix}`,
organizationId: organization.id,
externalRef: `test-${suffix}`,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `test-${suffix}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `apikey-${suffix}`,
pkApiKey: `pk-${suffix}`,
shortcode: `test-${suffix}`,
},
});
return { organization, project, environment };
}
async function seedWaitpoint(
prisma: PrismaClient,
id: string,
env: { id: string; projectId: string },
overrides: Partial<{
status: "PENDING" | "COMPLETED";
type: WaitpointType;
output: string;
outputType: string;
outputIsError: boolean;
completedAt: Date;
completedAfter: Date;
tags: string[];
}> = {}
) {
return prisma.waitpoint.create({
data: {
id,
friendlyId: `waitpoint_${id}`,
type: overrides.type ?? "MANUAL",
status: overrides.status ?? "COMPLETED",
idempotencyKey: `idem-${id}`,
userProvidedIdempotencyKey: false,
output: overrides.output ?? JSON.stringify({ hello: "world" }),
outputType: overrides.outputType ?? "application/json",
outputIsError: overrides.outputIsError ?? false,
completedAt: overrides.completedAt ?? new Date(),
completedAfter: overrides.completedAfter,
tags: overrides.tags ?? ["a", "b"],
projectId: env.projectId,
environmentId: env.id,
},
});
}
const environmentArg = (env: { id: string; projectId: string }) => ({
id: env.id,
type: "PRODUCTION" as const,
project: { id: env.projectId, engine: "V2" as const },
apiKey: "tr_test_apikey",
});
describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgres)", () => {
heteroPostgresTest(
"resolves on run-ops NEW (ksuid), legacy replica never touched",
async ({ prisma17, prisma14 }) => {
const id = generateKsuidId();
expect(id.length).toBe(27);
const { project, environment } = await seedOrgProjectEnv(prisma17, "new");
const seeded = await seedWaitpoint(
prisma17,
id,
{ id: environment.id, projectId: project.id },
{ tags: ["x", "y", "z"], output: JSON.stringify({ n: 42 }) }
);
const newClient = recording(prisma17);
const legacy = recording(prisma14, { forbidden: true });
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: newClient.handle,
legacyReplica: legacy.handle,
});
const result = await presenter.call(environmentArg(environment), id);
expect(result.id).toBe(seeded.friendlyId);
expect(result.tags).toEqual(["x", "y", "z"]);
expect(result.output).toBe(JSON.stringify({ n: 42 }));
expect(result.type).toBe("MANUAL");
// ksuid → NEW: new store served the read, legacy never touched (fast-path).
expect(newClient.calls.length).toBe(1);
expect(legacy.calls.length).toBe(0);
}
);
heteroPostgresTest(
"resolves off the LEGACY replica (cuid), never a legacy primary",
async ({ prisma17, prisma14 }) => {
const id = generateLegacyCuid();
expect(id.length).toBe(25);
const { project, environment } = await seedOrgProjectEnv(prisma14, "legacy");
const seeded = await seedWaitpoint(prisma14, id, {
id: environment.id,
projectId: project.id,
});
const newClient = recording(prisma17);
// The deps expose only legacyReplica — there is NO legacy-primary handle at all.
const legacy = recording(prisma14);
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: newClient.handle,
legacyReplica: legacy.handle,
});
const result = await presenter.call(environmentArg(environment), id);
expect(result.id).toBe(seeded.friendlyId);
expect(result.tags).toEqual(["a", "b"]);
// NEW probed first (miss) → resolved off the LEGACY REPLICA handle.
expect(newClient.calls.length).toBe(1);
expect(legacy.calls.length).toBe(1);
}
);
heteroPostgresTest(
"not-found maps to the existing ServiceValidationError surface",
async ({ prisma17, prisma14 }) => {
const id = generateLegacyCuid();
const { environment } = await seedOrgProjectEnv(prisma14, "nf");
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: recording(prisma17).handle,
legacyReplica: recording(prisma14).handle,
});
await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow(
"Waitpoint not found"
);
}
);
heteroPostgresTest(
"past-retention maps to the same not-found surface",
async ({ prisma17, prisma14 }) => {
const id = generateLegacyCuid();
const { environment } = await seedOrgProjectEnv(prisma14, "pr");
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: recording(prisma17).handle,
legacyReplica: recording(prisma14).handle,
isPastRetention: () => true,
});
await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow(
"Waitpoint not found"
);
}
);
heteroPostgresTest(
"cross-seam — new-resident served from NEW (legacy untouched); in-retention served from legacy",
async ({ prisma17, prisma14 }) => {
// New-resident waitpoint: lives on NEW, the new probe hits, legacy must never be touched.
const newId = generateKsuidId();
const newEnv = await seedOrgProjectEnv(prisma17, "x2new");
await seedWaitpoint(prisma17, newId, {
id: newEnv.environment.id,
projectId: newEnv.project.id,
});
const newLegacy = recording(prisma14, { forbidden: true });
const migratedPresenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: recording(prisma17).handle,
legacyReplica: newLegacy.handle,
});
const migratedResult = await migratedPresenter.call(
environmentArg(newEnv.environment),
newId
);
expect(migratedResult.id).toBe(`waitpoint_${newId}`);
expect(newLegacy.calls.length).toBe(0);
// In-retention waitpoint: lives on the legacy replica, served from it.
const oldId = generateLegacyCuid();
const oldEnv = await seedOrgProjectEnv(prisma14, "x2old");
await seedWaitpoint(prisma14, oldId, {
id: oldEnv.environment.id,
projectId: oldEnv.project.id,
});
const retentionPresenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: recording(prisma17).handle,
legacyReplica: recording(prisma14).handle,
});
const retentionResult = await retentionPresenter.call(
environmentArg(oldEnv.environment),
oldId
);
expect(retentionResult.id).toBe(`waitpoint_${oldId}`);
}
);
});
// Regression: the split-mode NEW client is the REAL scalar-only run-ops client (prisma17). A cuid
// classifies LEGACY, so readThroughRun probes NEW first — a relation in hydrate() (connectedRuns)
// throws PrismaClientValidationError there (the 500) before the legacy fallback runs.
describe("ApiWaitpointPresenter read-through (dedicated scalar-only run-ops NEW client)", () => {
heteroRunOpsPostgresTest(
"cuid token: hydrate() select is valid against the scalar-only run-ops client, resolves via legacy",
async ({ prisma17, prisma14 }) => {
const id = generateLegacyCuid();
expect(id.length).toBe(25);
const { project, environment } = await seedOrgProjectEnv(prisma14, "scalar-legacy");
const seeded = await seedWaitpoint(
prisma14,
id,
{ id: environment.id, projectId: project.id },
{ tags: ["p", "q"], output: JSON.stringify({ ok: true }) }
);
const newClient = recording(prisma17);
const legacy = recording(prisma14);
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: newClient.handle,
legacyReplica: legacy.handle,
});
// Must NOT throw PrismaClientValidationError; resolves the token off the legacy side.
const result = await presenter.call(environmentArg(environment), id);
expect(result.id).toBe(seeded.friendlyId);
expect(result.tags).toEqual(["p", "q"]);
expect(result.output).toBe(JSON.stringify({ ok: true }));
expect(newClient.calls.length).toBe(1);
expect(legacy.calls.length).toBe(1);
}
);
});
describe("ApiWaitpointPresenter passthrough (single-DB)", () => {
postgresTest(
"no read-through deps → one plain replica read; legacy never touched",
async ({ prisma }) => {
const id = generateKsuidId();
const { project, environment } = await seedOrgProjectEnv(prisma, "pt");
const seeded = await seedWaitpoint(
prisma,
id,
{ id: environment.id, projectId: project.id },
{ tags: ["one"], output: JSON.stringify({ ok: true }) }
);
const single = recording(prisma);
const legacy = recording(prisma, { forbidden: true });
// No splitEnabled → passthrough. newClient defaults to the single recording handle so we
// can assert exactly one read against it; legacy must never fire.
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
newClient: single.handle,
legacyReplica: legacy.handle,
});
const result = await presenter.call(environmentArg(environment), id);
expect(result.id).toBe(seeded.friendlyId);
expect(result.tags).toEqual(["one"]);
expect(result.output).toBe(JSON.stringify({ ok: true }));
// Passthrough: exactly one read on the single client; legacy untouched.
expect(single.calls.length).toBe(1);
expect(legacy.calls.length).toBe(0);
}
);
});
@@ -0,0 +1,484 @@
import { Prisma } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
// BatchListPresenter imports `~/db.server` (for `sqlDatabaseSchema` + `PrismaClientOrTransaction`),
// `~/models/runtimeEnvironment.server`, and `~/components/*` at load — all of which pull
// `env.server` at import time. Stub `~/db.server` to break that chain (the runsRepository
// read-through test does the same). The presenter is driven entirely through injected real
// containers; the only thing it actually reads off this module is `sqlDatabaseSchema`, which we
// reproduce as the real `Prisma.sql(["public"])` value so the schema-qualified raw scan SQL is valid.
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
sqlDatabaseSchema: Prisma.sql(["public"]),
}));
import {
heteroPostgresTest,
heteroRunOpsPostgresTest,
postgresTest,
} from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import {
type BatchListOptions,
BatchListPresenter,
} from "~/presenters/v3/BatchListPresenter.server";
vi.setConfig({ testTimeout: 120_000 });
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
userId: string;
};
// The exact presenter scan SQL, lifted verbatim, so tests can compare the presenter's output
// against a direct $queryRaw of the identical SQL on each DB version.
function rawScan(
prisma: PrismaClient,
opts: {
environmentId: string;
pageSize: number;
direction: "forward" | "backward";
cursor?: string;
}
) {
const { environmentId, pageSize, direction, cursor } = opts;
const sqlDatabaseSchema = Prisma.sql(["public"]);
return prisma.$queryRaw<
{
id: string;
friendlyId: string;
runtimeEnvironmentId: string;
status: any;
createdAt: Date;
updatedAt: Date;
completedAt: Date | null;
runCount: bigint;
batchVersion: string;
}[]
>`
SELECT
b.id,
b."friendlyId",
b."runtimeEnvironmentId",
b.status,
b."createdAt",
b."updatedAt",
b."completedAt",
b."runCount",
b."batchVersion"
FROM
${sqlDatabaseSchema}."BatchTaskRun" b
WHERE
b."runtimeEnvironmentId" = ${environmentId}
${
cursor
? direction === "forward"
? Prisma.sql`AND b.id < ${cursor}`
: Prisma.sql`AND b.id > ${cursor}`
: Prisma.empty
}
ORDER BY
${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`}
LIMIT ${pageSize + 1}`;
}
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
const user = await prisma.user.create({
data: {
email: `user-${slug}@example.com`,
name: `User ${slug}`,
authenticationMethod: "MAGIC_LINK",
},
});
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
const orgMember = await prisma.orgMember.create({
data: { organizationId: organization.id, userId: user.id },
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
orgMemberId: orgMember.id,
apiKey: `tr_dev_${slug}`,
pkApiKey: `pk_dev_${slug}`,
shortcode: `sc-${slug}`,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: runtimeEnvironment.id,
userId: user.id,
};
}
// Mirrors the org/project/env parents onto a second DB with the SAME ids (BatchTaskRun FK needs
// the runtimeEnvironment to exist on whichever DB the row lives on).
async function mirrorEnvParents(
prisma: PrismaClient,
ctx: SeedContext,
slug: string
): Promise<void> {
const organization = await prisma.organization.create({
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
id: ctx.projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environmentId,
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${slug}_m`,
pkApiKey: `pk_dev_${slug}_m`,
shortcode: `sc-${slug}-m`,
},
});
}
async function createBatch(
prisma: PrismaClient,
ctx: SeedContext,
batch: {
id: string;
friendlyId: string;
status?: any;
batchVersion?: string;
runCount?: number;
createdAt?: Date;
}
) {
return prisma.batchTaskRun.create({
data: {
id: batch.id,
friendlyId: batch.friendlyId,
runtimeEnvironmentId: ctx.environmentId,
status: batch.status ?? "PENDING",
batchVersion: batch.batchVersion ?? "v3",
runCount: batch.runCount ?? 1,
...(batch.createdAt ? { createdAt: batch.createdAt } : {}),
},
});
}
const baseCall = (
ctx: SeedContext,
overrides: Partial<BatchListOptions> = {}
): BatchListOptions => ({
projectId: ctx.projectId,
environmentId: ctx.environmentId,
userId: ctx.userId,
...overrides,
});
// Wraps a prisma client so the test can assert whether/how often batchTaskRun.findMany or
// batchTaskRun.findFirst are invoked. Optionally throws if invoked (proves a handle is never touched).
function spyClient(
prisma: PrismaClient,
opts: { throwOnQueryRaw?: boolean; throwOnFindFirst?: boolean } = {}
) {
const counts = { queryRaw: 0, findMany: 0, findFirst: 0 };
const proxy = new Proxy(prisma, {
get(target, prop, receiver) {
if (prop === "batchTaskRun") {
const real = (target as any).batchTaskRun;
return new Proxy(real, {
get(trTarget, trProp) {
if (trProp === "findMany") {
return (...args: any[]) => {
counts.findMany++;
if (opts.throwOnQueryRaw)
throw new Error("batchTaskRun.findMany must not be invoked on this handle");
return (trTarget as any).findMany(...args);
};
}
if (trProp === "findFirst") {
return (...args: any[]) => {
counts.findFirst++;
if (opts.throwOnFindFirst)
throw new Error("batchTaskRun.findFirst must not be invoked on this handle");
return (trTarget as any).findFirst(...args);
};
}
return (trTarget as any)[trProp];
},
});
}
return Reflect.get(target, prop, receiver);
},
}) as unknown as PrismaClient;
return { client: proxy, counts };
}
const desc = (a: string, b: string) => (a < b ? 1 : a > b ? -1 : 0);
describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + PG17 new)", () => {
// Byte-identical scan + identical ORDER-BY across PG14/PG17.
heteroPostgresTest(
"raw paginated scan is byte-identical and identically ordered across PG14 and PG17 (both directions, with/without cursor)",
async ({ prisma14, prisma17 }) => {
const ctx14 = await seedParents(prisma14, "scan");
const ctx17 = await seedParents(prisma17, "scan");
// Identical corpus on both sides (same logical ids), exercising statuses + batchVersion +
// createdAt spanning a period, and keyset cursor boundaries.
const ids = ["aaaa", "bbbb", "cccc", "dddd", "eeee"];
const statuses = ["PENDING", "COMPLETED", "ABORTED", "PROCESSING", "COMPLETED"];
const versions = ["v3", "v3", "v1", "v2", "v3"];
for (let i = 0; i < ids.length; i++) {
await createBatch(prisma14, ctx14, {
id: `batch_${ids[i]}`,
friendlyId: `fr_${ids[i]}`,
status: statuses[i],
batchVersion: versions[i],
runCount: i + 1,
createdAt: new Date(Date.now() - i * 60_000),
});
await createBatch(prisma17, ctx17, {
id: `batch_${ids[i]}`,
friendlyId: `fr_${ids[i]}`,
status: statuses[i],
batchVersion: versions[i],
runCount: i + 1,
createdAt: new Date(Date.now() - i * 60_000),
});
}
for (const direction of ["forward", "backward"] as const) {
for (const cursor of [undefined, "batch_cccc"]) {
const rows14 = await rawScan(prisma14, {
environmentId: ctx14.environmentId,
pageSize: 2,
direction,
cursor,
});
const rows17 = await rawScan(prisma17, {
environmentId: ctx17.environmentId,
pageSize: 2,
direction,
cursor,
});
// ids are identical across both DBs; rows must match byte-for-byte and in order.
expect(rows14.map((r) => r.id)).toEqual(rows17.map((r) => r.id));
expect(rows14.map((r) => r.friendlyId)).toEqual(rows17.map((r) => r.friendlyId));
expect(rows14.map((r) => r.runCount)).toEqual(rows17.map((r) => r.runCount));
expect(rows14.map((r) => r.status)).toEqual(rows17.map((r) => r.status));
// ORDER-BY parity: forward => id DESC, backward => id ASC.
const order = rows14.map((r) => r.id);
const expected = [...order].sort(direction === "forward" ? desc : (a, b) => -desc(a, b));
expect(order).toEqual(expected);
}
}
// The TS codepoint comparator reproduces the DB ORDER BY over the seeded id set.
const allIds = ids.map((i) => `batch_${i}`);
const dbForward = (
await rawScan(prisma17, {
environmentId: ctx17.environmentId,
pageSize: 50,
direction: "forward",
})
).map((r) => r.id);
expect(dbForward).toEqual([...allIds].sort(desc));
}
);
// Split scan merge serves new + legacy in one keyset-ordered page.
heteroPostgresTest(
"split scan merges new (PG17) + legacy (PG14) rows under the keyset order; legacy read only when new does not fill the page",
async ({ prisma14, prisma17 }) => {
const ctx14 = await seedParents(prisma14, "merge");
await mirrorEnvParents(prisma17, ctx14, "merge");
// Interleaved ids across the keyset order. New (migrated) on PG17, legacy on PG14.
await createBatch(prisma17, ctx14, { id: "batch_a", friendlyId: "fr_a", runCount: 1 });
await createBatch(prisma14, ctx14, { id: "batch_b", friendlyId: "fr_b", runCount: 2 });
await createBatch(prisma17, ctx14, { id: "batch_c", friendlyId: "fr_c", runCount: 3 });
await createBatch(prisma14, ctx14, { id: "batch_d", friendlyId: "fr_d", runCount: 4 });
await createBatch(prisma17, ctx14, { id: "batch_e", friendlyId: "fr_e", runCount: 5 });
// Case A: small page fully served by new alone => legacy NOT read.
const legacySpyA = spyClient(prisma14);
const presenterA = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: legacySpyA.client,
controlPlaneReplica: prisma14,
splitEnabled: true,
});
const pageA = await presenterA.call(baseCall(ctx14, { pageSize: 2 }));
// new ids are e, c, a -> DESC: e, c (pageSize 2). pageSize+1 = 3 rows from new fills the page.
expect(pageA.batches.map((b) => b.id)).toEqual(["batch_e", "batch_c"]);
expect(legacySpyA.counts.findMany).toBe(0);
// Case B: page needs legacy rows => legacy IS read and the merge is keyset-ordered union.
const legacySpyB = spyClient(prisma14);
const presenterB = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: legacySpyB.client,
controlPlaneReplica: prisma14,
splitEnabled: true,
});
const pageB = await presenterB.call(baseCall(ctx14, { pageSize: 4 }));
// union DESC of all 5: e, d, c, b, a -> first 4.
expect(pageB.batches.map((b) => b.id)).toEqual(["batch_e", "batch_d", "batch_c", "batch_b"]);
expect(legacySpyB.counts.findMany).toBeGreaterThan(0);
// cursor parity: next is the 4th id (pageSize-th), previous undefined (no input cursor).
expect(pageB.pagination.next).toBe("batch_b");
expect(pageB.pagination.previous).toBeUndefined();
expect(pageB.hasAnyBatches).toBe(true);
}
);
// Project resolves on control-plane; no cross-seam join.
heteroPostgresTest(
"project resolves on the control-plane handle (PG14); BatchTaskRun scan reads run-ops only",
async ({ prisma14, prisma17 }) => {
// Project/env/orgMember/user only on PG14 (control-plane). BatchTaskRun env mirrored to PG17.
const ctx = await seedParents(prisma14, "cp");
await mirrorEnvParents(prisma17, ctx, "cp");
await createBatch(prisma17, ctx, { id: "batch_cp1", friendlyId: "fr_cp1", runCount: 7 });
// controlPlaneReplica must never run the BatchTaskRun raw scan.
const cpSpy = spyClient(prisma14, { throwOnQueryRaw: true });
// runOpsNew must never run a project lookup — guard by making project absent on PG17.
const presenter = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: prisma14,
controlPlaneReplica: cpSpy.client,
splitEnabled: true,
});
const page = await presenter.call(baseCall(ctx, { pageSize: 10 }));
expect(page.batches.map((b) => b.id)).toEqual(["batch_cp1"]);
// displayableEnvironment mapped by in-memory id match.
expect(page.batches[0].environment.id).toBe(ctx.environmentId);
expect(page.batches[0].environment.type).toBe("DEVELOPMENT");
// control-plane handle was used (project read) but never for the batch scan.
expect(cpSpy.counts.findMany).toBe(0);
}
);
// Empty-state probe is dual-DB during the window.
heteroPostgresTest(
"empty-state probe reads new then legacy replica: true when legacy has a batch, false when both empty",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "probe");
await mirrorEnvParents(prisma17, ctx, "probe");
// Zero batches on new (PG17), one on legacy (PG14). A filter that yields an empty page.
await createBatch(prisma14, ctx, { id: "batch_legacy_only", friendlyId: "fr_legacy_only" });
const presenter = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: prisma14,
controlPlaneReplica: prisma14,
splitEnabled: true,
});
// friendlyId filter that matches nothing => empty page, probe must still find the legacy row.
const page = await presenter.call(baseCall(ctx, { friendlyId: "fr_does_not_exist" }));
expect(page.batches).toHaveLength(0);
expect(page.hasAnyBatches).toBe(true);
// Now wipe legacy too => both empty => hasAnyBatches false.
await prisma14.batchTaskRun.deleteMany({
where: { runtimeEnvironmentId: ctx.environmentId },
});
const page2 = await presenter.call(baseCall(ctx, { friendlyId: "fr_does_not_exist" }));
expect(page2.batches).toHaveLength(0);
expect(page2.hasAnyBatches).toBe(false);
}
);
// Single-DB passthrough collapses to one handle.
postgresTest(
"passthrough (no readRoute): scan + probe + project all read the single handle; legacy closures never invoked",
async ({ prisma }) => {
const ctx = await seedParents(prisma, "pass");
await createBatch(prisma, ctx, { id: "batch_p1", friendlyId: "fr_p1", runCount: 3 });
await createBatch(prisma, ctx, { id: "batch_p2", friendlyId: "fr_p2", runCount: 4 });
await createBatch(prisma, ctx, { id: "batch_p3", friendlyId: "fr_p3", runCount: 5 });
const presenter = new BatchListPresenter(prisma, prisma);
const page = await presenter.call(baseCall(ctx, { pageSize: 2 }));
// Page content + ordering + cursors equal a direct $queryRaw of the same SQL.
const direct = await rawScan(prisma, {
environmentId: ctx.environmentId,
pageSize: 2,
direction: "forward",
});
const hasMore = direct.length > 2;
const expectedPage = direct.slice(0, 2);
expect(page.batches.map((b) => b.id)).toEqual(expectedPage.map((r) => r.id));
expect(page.pagination.next).toBe(hasMore ? expectedPage[1].id : undefined);
expect(page.pagination.previous).toBeUndefined();
expect(page.hasAnyBatches).toBe(true);
// A throwing legacy handle proves the split branch is never entered in passthrough.
const throwingLegacy = spyClient(prisma, { throwOnQueryRaw: true, throwOnFindFirst: true });
const presenterWithUnusedLegacy = new BatchListPresenter(prisma, prisma, {
runOpsLegacyReplica: throwingLegacy.client,
// splitEnabled omitted => passthrough; legacy must never be touched.
});
const page2 = await presenterWithUnusedLegacy.call(baseCall(ctx, { pageSize: 2 }));
expect(page2.batches.map((b) => b.id)).toEqual(expectedPage.map((r) => r.id));
expect(throwingLegacy.counts.findMany).toBe(0);
expect(throwingLegacy.counts.findFirst).toBe(0);
}
);
heteroRunOpsPostgresTest(
"scan against dedicated RunOpsPrismaClient (splitEnabled): returns batches from new DB",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "rawscan-batch14");
// runtimeEnvironmentId is a FK-free scalar in the run-ops schema — no parent row needed.
await (prisma17 as RunOpsPrismaClient).batchTaskRun.create({
data: {
id: "rbatch00000000000000001",
friendlyId: "fr_rbatch00000000000000001",
runtimeEnvironmentId: ctx.environmentId,
status: "PENDING",
batchVersion: "v3",
runCount: 2,
},
});
const presenter = new BatchListPresenter(prisma14 as any, prisma14 as any, {
runOpsNew: prisma17 as any,
runOpsLegacyReplica: prisma14 as any,
controlPlaneReplica: prisma14 as any,
splitEnabled: true,
});
const page = await presenter.call(baseCall(ctx, { pageSize: 10 }));
expect(page.batches.map((b) => b.id)).toContain("rbatch00000000000000001");
}
);
});
+360
View File
@@ -0,0 +1,360 @@
import { containerTest, heteroPostgresTest } from "@internal/testcontainers";
import { type PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import {
displayableEnvironment,
type DisplayableInputEnvironment,
} from "~/models/runtimeEnvironment.server";
import { BatchPresenter } from "~/presenters/v3/BatchPresenter.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
vi.setConfig({ testTimeout: 90_000 });
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
};
/**
* Seeds the control-plane org/project/env on one DB. The env is control-plane; it is
* resolved separately from the run-ops batch row (the cross-seam FK is physically dropped),
* so this always lives on the same DB that the injected resolveDisplayableEnvironment reads.
*/
async function seedEnvironment(
prisma: PrismaClient,
slug: string,
type: "DEVELOPMENT" | "PRODUCTION" = "PRODUCTION"
): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
let orgMemberId: string | undefined;
if (type === "DEVELOPMENT") {
const user = await prisma.user.create({
data: {
email: `user-${slug}@example.com`,
name: `User ${slug}`,
displayName: `Display ${slug}`,
authenticationMethod: "MAGIC_LINK",
},
});
const member = await prisma.orgMember.create({
data: { organizationId: organization.id, userId: user.id, role: "ADMIN" },
});
orgMemberId = member.id;
}
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type,
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_${slug}`,
pkApiKey: `pk_${slug}`,
shortcode: `sc-${slug}`,
orgMemberId,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: environment.id,
};
}
async function seedBatch(
prisma: PrismaClient,
environmentId: string,
opts: {
friendlyId: string;
status?: any;
batchVersion?: string;
runCount?: number;
withError?: boolean;
}
) {
return prisma.batchTaskRun.create({
data: {
friendlyId: opts.friendlyId,
runtimeEnvironmentId: environmentId,
status: opts.status ?? "COMPLETED",
batchVersion: opts.batchVersion ?? "v1",
runCount: opts.runCount ?? 0,
successfulRunCount: 3,
failedRunCount: 1,
idempotencyKey: `idem-${opts.friendlyId}`,
errors: opts.withError
? {
create: [
{
index: 0,
taskIdentifier: "my-task",
error: JSON.stringify({ message: "boom", stack: "x\ny" }),
errorCode: "TASK_RUN_FAILED",
},
],
}
: undefined,
},
});
}
/**
* Builds a resolveDisplayableEnvironment closure over a real control-plane container exactly
* mirroring the production findDisplayableEnvironment (reads control-plane, returns the
* displayableEnvironment shape). This is the only injected boundary; the DB is never mocked.
*/
function makeEnvResolver(controlPlane: PrismaClient) {
return async (environmentId: string, userId: string | undefined) => {
const environment = await controlPlane.runtimeEnvironment.findFirst({
where: { id: environmentId },
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: { user: { select: { id: true, name: true, displayName: true } } },
},
},
});
if (!environment) {
return undefined;
}
return displayableEnvironment(environment as DisplayableInputEnvironment, userId);
};
}
describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => {
// Batch detail resolves on run-ops NEW (split on). Legacy replica is never probed.
heteroPostgresTest(
"resolves a NEW-resident batch and never probes the legacy replica",
async ({ prisma14, prisma17 }) => {
const ctx = await seedEnvironment(prisma17, "new1");
await seedBatch(prisma17, ctx.environmentId, {
friendlyId: "batch_new1",
withError: true,
runCount: 4,
});
// Real readThroughRun. A tripwire legacy client throws if batchTaskRun is ever accessed,
// proving a NEW-resident row is served without probing the legacy replica.
const tripwireLegacy = new Proxy(prisma14, {
get(target, prop) {
if (prop === "batchTaskRun") {
throw new Error("legacy replica must not be probed for a NEW-resident batch");
}
return (target as any)[prop];
},
}) as unknown as PrismaClient;
const presenter = new BatchPresenter(undefined, undefined, {
splitEnabled: true,
newClient: prisma17,
legacyReplica: tripwireLegacy,
readThrough: readThroughRun,
resolveDisplayableEnvironment: makeEnvResolver(prisma17),
});
const result = await presenter.call({
environmentId: ctx.environmentId,
batchId: "batch_new1",
});
expect(result.friendlyId).toBe("batch_new1");
expect(result.runCount).toBe(4);
expect(result.idempotencyKey).toBe("idem-batch_new1");
expect(result.errors).toHaveLength(1);
expect(result.errors[0].errorCode).toBe("TASK_RUN_FAILED");
expect(result.environment.id).toBe(ctx.environmentId);
expect(result.environment.type).toBe("PRODUCTION");
}
);
// Batch detail resolves on run-ops OLD/legacy READ REPLICA (split on, in-retention).
// Cross-version round-trip: PG14 legacy -> presenter, JSON error payload byte-identical.
heteroPostgresTest(
"resolves a legacy-only batch via the legacy READ REPLICA, byte-identical",
async ({ prisma14, prisma17 }) => {
// Env is control-plane (lives wherever the resolver reads); batch is run-ops, legacy-only.
const ctx = await seedEnvironment(prisma14, "legacy1");
const errorPayload = JSON.stringify({ message: "legacy boom", stack: "a\nb\nc" });
await prisma14.batchTaskRun.create({
data: {
friendlyId: "batch_legacy1",
runtimeEnvironmentId: ctx.environmentId,
status: "COMPLETED",
batchVersion: "v1",
runCount: 2,
successfulRunCount: 1,
failedRunCount: 1,
idempotencyKey: "idem-batch_legacy1",
errors: {
create: [
{
index: 0,
taskIdentifier: "legacy-task",
error: errorPayload,
errorCode: "LEGACY_CODE",
},
],
},
},
});
// The structural guarantee: there is no legacy-PRIMARY handle in readThroughRun; the only
// legacy handle threaded here is the read replica (prisma14).
const presenter = new BatchPresenter(undefined, undefined, {
splitEnabled: true,
newClient: prisma17, // NEW probe misses (nothing seeded there)
legacyReplica: prisma14,
// Real readThroughRun; the NEW miss falls through to the legacy replica.
readThrough: readThroughRun,
resolveDisplayableEnvironment: makeEnvResolver(prisma14),
});
const result = await presenter.call({
environmentId: ctx.environmentId,
batchId: "batch_legacy1",
});
expect(result.friendlyId).toBe("batch_legacy1");
expect(result.runCount).toBe(2);
expect(result.errors).toHaveLength(1);
// JSON error payload round-trips byte-identically across PG14 -> presenter.
expect(result.errors[0].error).toBe(errorPayload);
expect(result.errors[0].taskIdentifier).toBe("legacy-task");
expect(result.environment.id).toBe(ctx.environmentId);
}
);
// Post-termination / not-found yields the normal "Batch not found".
heteroPostgresTest(
"throws the normal not-found when the batch is absent from both stores",
async ({ prisma14, prisma17 }) => {
const ctx = await seedEnvironment(prisma14, "missing1");
const presenter = new BatchPresenter(undefined, undefined, {
splitEnabled: true,
newClient: prisma17,
legacyReplica: prisma14,
readThrough: readThroughRun,
resolveDisplayableEnvironment: makeEnvResolver(prisma14),
});
await expect(
presenter.call({ environmentId: ctx.environmentId, batchId: "batch_does_not_exist" })
).rejects.toThrow("Batch not found");
}
);
// Env decoupling parity for a DEVELOPMENT env (userName branch).
heteroPostgresTest(
"resolves the DEVELOPMENT env userName separately from the run-ops batch row",
async ({ prisma14, prisma17 }) => {
const ctx = await seedEnvironment(prisma17, "dev1", "DEVELOPMENT");
await seedBatch(prisma17, ctx.environmentId, { friendlyId: "batch_dev1" });
const presenter = new BatchPresenter(undefined, undefined, {
splitEnabled: true,
newClient: prisma17,
legacyReplica: prisma14,
readThrough: readThroughRun,
resolveDisplayableEnvironment: makeEnvResolver(prisma17),
});
// No userId passed -> userName resolves to the member's username (the orgMember branch).
const result = await presenter.call({
environmentId: ctx.environmentId,
batchId: "batch_dev1",
});
expect(result.environment.type).toBe("DEVELOPMENT");
expect(result.environment.userName).toBe("Display dev1");
}
);
});
describe("BatchPresenter single-DB passthrough", () => {
// Passthrough + self-host collapse: one plain read, legacy closure never invoked.
containerTest(
"single-DB resolves the batch with one plain read and never touches the legacy boundary",
async ({ prisma }) => {
const ctx = await seedEnvironment(prisma, "passthrough");
await seedBatch(prisma, ctx.environmentId, {
friendlyId: "batch_passthrough",
withError: true,
runCount: 5,
});
let legacyInvoked = false;
const presenter = new BatchPresenter(prisma, prisma, {
splitEnabled: false,
// Pass the single DB as both clients; the passthrough must read NEW only.
newClient: prisma,
legacyReplica: new Proxy(prisma, {
get(target, prop) {
if (prop === "batchTaskRun") {
legacyInvoked = true;
throw new Error("legacy boundary must not be touched in single-DB passthrough");
}
return (target as any)[prop];
},
}) as unknown as PrismaClient,
resolveDisplayableEnvironment: makeEnvResolver(prisma),
});
const result = await presenter.call({
environmentId: ctx.environmentId,
batchId: "batch_passthrough",
});
expect(result.friendlyId).toBe("batch_passthrough");
expect(result.runCount).toBe(5);
expect(result.errors).toHaveLength(1);
expect(result.environment.id).toBe(ctx.environmentId);
expect(legacyInvoked).toBe(false);
}
);
// e2e #3 scoped proxy: a batch whose members span migrated + abandoned runs still resolves
// at the batch-record level. Scope: BatchPresenter reads only the batch row, not its member
// TaskRuns — the dangling-reference gate over members is owned by the migration / dangling-gate
// units, not this presenter. This unit's contribution is "batch detail loads regardless of
// which run-ops store holds the batch row."
containerTest(
"e2e #3 proxy: a batch spanning migrated + abandoned runs still resolves",
async ({ prisma }) => {
const ctx = await seedEnvironment(prisma, "e2e3");
await seedBatch(prisma, ctx.environmentId, {
friendlyId: "batch_e2e3",
runCount: 10, // implies members spanning migrated + abandoned runs
});
const presenter = new BatchPresenter(prisma, prisma, {
splitEnabled: false,
newClient: prisma,
resolveDisplayableEnvironment: makeEnvResolver(prisma),
});
const result = await presenter.call({
environmentId: ctx.environmentId,
batchId: "batch_e2e3",
});
expect(result.friendlyId).toBe("batch_e2e3");
expect(result.runCount).toBe(10);
}
);
});
@@ -0,0 +1,450 @@
import { describe, expect, vi } from "vitest";
// The presenter graph imports `~/v3/runStore.server` (via RunsRepository) which imports
// `~/db.server` at load, and the presenter itself reaches `~/db.server`'s `$replica` singleton
// through `findDisplayableEnvironment` and `getTaskIdentifiers`. Stub the module so those
// singleton reads resolve. This is the ONLY mock — the DB is NEVER mocked; the `$replica`
// stub delegates to the per-test REAL legacy container so the env-lookup + task-identifier
// reads hit a real database. Everything asserted runs against real containers.
//
// `legacyReplicaHolder.client` is set by each test to its real legacy `prisma` handle before
// calling the presenter; the proxy forwards every property access to it lazily. Created via
// vi.hoisted so it exists when the hoisted vi.mock factory runs.
// `legacyReplicaHolder.client` -> the legacy handle backing the `prisma`/`$replica`
// singletons; `newClientHolder.client` -> the new handle backing `runOpsNewPrisma`
// (used by the routed store's default known-migrated probe). Each test sets both before calling.
const legacyReplicaHolder = vi.hoisted(() => ({ client: undefined as any }));
const newClientHolder = vi.hoisted(() => ({ client: undefined as any }));
vi.mock("~/db.server", async () => {
const { Prisma } = await import("@trigger.dev/database");
const lazyProxy = (holder: { client: any }, label: string) =>
new Proxy(
{},
{
get(_t, prop) {
if (!holder.client) {
throw new Error(`${label} not set for this test`);
}
return holder.client[prop];
},
}
);
const replicaProxy = lazyProxy(legacyReplicaHolder, "legacyReplicaHolder.client");
const newProxy = lazyProxy(newClientHolder, "newClientHolder.client");
return {
prisma: replicaProxy,
$replica: replicaProxy,
runOpsNewPrisma: newProxy,
runOpsNewReplica: newProxy,
runOpsLegacyPrisma: replicaProxy,
runOpsLegacyReplica: replicaProxy,
sqlDatabaseSchema: Prisma.sql([`public`]),
};
});
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
import { PrismaClient } from "@trigger.dev/database";
import { setTimeout } from "node:timers/promises";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { setupClickhouseReplication } from "./utils/replicationUtils";
vi.setConfig({ testTimeout: 90_000 });
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
};
/**
* Creates the org/project/env parents on a single prisma client. TaskRun FKs require these to
* exist on every DB a run lives on, so identical parents (same ids) are seeded on both the
* legacy and new databases.
*/
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${slug}`,
pkApiKey: `pk_dev_${slug}`,
shortcode: `sc-${slug}`,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: runtimeEnvironment.id,
};
}
/** Mirrors the org/project/env parents onto a second DB with the SAME ids. */
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
await prisma.organization.create({
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: ctx.projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: ctx.organizationId,
externalRef: `proj-${slug}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environmentId,
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: ctx.projectId,
organizationId: ctx.organizationId,
apiKey: `tr_dev_${slug}_b`,
pkApiKey: `pk_dev_${slug}_b`,
shortcode: `sc-${slug}-b`,
},
});
}
async function createRun(
prisma: PrismaClient,
ctx: SeedContext,
run: { friendlyId: string; taskIdentifier?: string; status?: any; runTags?: string[] }
) {
return prisma.taskRun.create({
data: {
friendlyId: run.friendlyId,
taskIdentifier: run.taskIdentifier ?? "my-task",
status: run.status ?? "PENDING",
payload: JSON.stringify({ foo: run.friendlyId }),
traceId: run.friendlyId,
spanId: run.friendlyId,
queue: "test",
runTags: run.runTags ?? [],
runtimeEnvironmentId: ctx.environmentId,
projectId: ctx.projectId,
organizationId: ctx.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
}
/**
* Wraps a real prisma handle in a Proxy whose `taskRun.findFirst` throws if invoked. Used to
* prove the empty-state probe never touches the legacy replica when the new DB already answers.
* All other access forwards to the real client (so FK parents etc. still resolve).
*/
function throwingFindFirst(prisma: PrismaClient, label: string): PrismaClient {
return new Proxy(prisma, {
get(target, prop) {
if (prop === "taskRun") {
return new Proxy((target as any).taskRun, {
get(trTarget, trProp) {
if (trProp === "findFirst") {
return async () => {
throw new Error(`${label}.taskRun.findFirst must not be invoked`);
};
}
return (trTarget as any)[trProp];
},
});
}
return (target as any)[prop];
},
}) as unknown as PrismaClient;
}
const callOptions = (ctx: SeedContext, overrides?: { to?: number }) => ({
projectId: ctx.projectId,
pageSize: 10,
...overrides,
});
// `to` one hour in the past. The CH page filters `created_at <= to`, so a just-created run is
// deterministically excluded regardless of replication timing — the empty-state tests otherwise
// raced on the run not having replicated yet (held locally, failed on CI). The PG existence probe
// has no time filter, so it still finds the row and `hasAnyRuns` stays true.
const emptyPageWindow = (): { to: number } => ({ to: Date.now() - 60 * 60 * 1000 });
describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legacy + new Postgres)", () => {
// no-false-empty. Runs ONLY on legacy, none on new. Empty CH page -> listRuns returns [].
// splitEnabled true. The probe misses NEW, falls through to the legacy replica and finds the
// row, so the dashboard must NOT show "no runs".
replicationContainerTest(
"no-false-empty: runs only on the legacy replica still report hasAnyRuns true",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
legacyReplicaHolder.client = prisma;
try {
const ctx = await seedParents(prisma, "nofalseempty");
await mirrorParents(prismaNew, ctx, "nofalseempty");
// Run lives ONLY on the legacy DB. We seed it to legacy and never wait for CH replication,
// so within the page window the CH id-set page is empty and listRuns returns []. The
// empty-state probe (NEW miss -> legacy hit) is what proves hasAnyRuns stays true.
await createRun(prisma, ctx, { friendlyId: "run_legacyOnly" });
const presenter = new NextRunListPresenter(prisma, clickhouse, {
newClient: prismaNew,
legacyReplica: prisma,
splitEnabled: true,
});
const result = await presenter.call(
ctx.organizationId,
ctx.environmentId,
callOptions(ctx, emptyPageWindow())
);
// CH id-set is empty within the page window, but the legacy probe finds the row.
expect(result.runs).toHaveLength(0);
expect(result.hasAnyRuns).toBe(true);
} finally {
await prismaNew.$disconnect();
}
}
);
// new-DB short-circuit. A run on NEW, legacy replica wrapped so its taskRun.findFirst
// throws. Empty CH page. The probe answers from NEW and must NEVER fall through to legacy. The
// post-migration straggler is the same shape: present on NEW, absent from LEGACY, legacy never
// invoked.
replicationContainerTest(
"new-DB short-circuit: hasAnyRuns answered from the new DB without touching the legacy replica",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
legacyReplicaHolder.client = prisma;
try {
const ctx = await seedParents(prisma, "newshortcircuit");
await mirrorParents(prismaNew, ctx, "newshortcircuit");
// The (migrated/straggler) run lives ONLY on NEW.
await createRun(prismaNew, ctx, { friendlyId: "run_newOnly" });
const legacySpy = throwingFindFirst(prisma, "legacyReplica");
const presenter = new NextRunListPresenter(prisma, clickhouse, {
newClient: prismaNew,
legacyReplica: legacySpy,
splitEnabled: true,
});
// If the legacy spy were invoked, this would throw — the test passing IS the proof.
const result = await presenter.call(
ctx.organizationId,
ctx.environmentId,
callOptions(ctx)
);
expect(result.runs).toHaveLength(0);
expect(result.hasAnyRuns).toBe(true);
} finally {
await prismaNew.$disconnect();
}
}
);
// genuinely empty. Nothing on either DB. Empty CH page. splitEnabled true. Both
// probes run and return null -> the true empty state is preserved.
replicationContainerTest(
"genuinely empty: both DBs empty reports hasAnyRuns false",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
legacyReplicaHolder.client = prisma;
try {
const ctx = await seedParents(prisma, "trulyempty");
await mirrorParents(prismaNew, ctx, "trulyempty");
const presenter = new NextRunListPresenter(prisma, clickhouse, {
newClient: prismaNew,
legacyReplica: prisma,
splitEnabled: true,
});
const result = await presenter.call(
ctx.organizationId,
ctx.environmentId,
callOptions(ctx)
);
expect(result.runs).toHaveLength(0);
expect(result.hasAnyRuns).toBe(false);
} finally {
await prismaNew.$disconnect();
}
}
);
// passthrough single-DB (two-arg ctor). One `prisma`, seed a run, empty CH page.
// splitEnabled defaults false -> exactly one plain findFirst against the single handle; the
// split branch (new/legacy) is structurally never entered (no second handle is injected).
// Also covers "served from the replica only" — the ctor exposes no legacy-writer field, so a
// no-primary-read guarantee is structural.
replicationContainerTest(
"passthrough single-DB: two-arg ctor finds the run via the single handle",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
legacyReplicaHolder.client = prisma;
const ctx = await seedParents(prisma, "passthrough");
await createRun(prisma, ctx, { friendlyId: "run_passthrough" });
// Two-arg ctor: no readThroughDeps -> RunsRepository.readThrough is undefined
// (passthrough) and the probe is one plain `this.replica.taskRun.findFirst`.
const presenter = new NextRunListPresenter(prisma, clickhouse);
const result = await presenter.call(
ctx.organizationId,
ctx.environmentId,
callOptions(ctx, emptyPageWindow())
);
expect(result.runs).toHaveLength(0);
expect(result.hasAnyRuns).toBe(true);
}
);
// list hydrate flows through the routed store: split, non-empty CH id-set whose rows are
// split across NEW + the legacy replica. result.runs must be the union, id-desc ordered. This
// proves the deps are threaded so the routed store is actually used.
// We assert the rows that DO surface (the full union, since legacy is probed for any id that
// misses on NEW).
// The migrated runs (run_newA/run_newB) live on BOTH DBs with the same id + friendlyId but a
// DISTINGUISHING taskIdentifier: "my-task" on legacy, "my-task-NEW" on new. #hydrateRunsByIds
// takes NEW rows first and only probes legacy for ids NOT on NEW, so a migrated row can only
// carry "my-task-NEW" if it was served from the threaded newClient (new DB) — asserted below.
replicationContainerTest(
"list hydrate flows through the routed store: result.runs is the NEW + legacy union, id-desc",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
legacyReplicaHolder.client = prisma;
// The routed store's default known-migrated probe reads `runOpsNewPrisma` -> the new DB.
newClientHolder.client = prismaNew;
try {
const ctx = await seedParents(prisma, "hydrate");
await mirrorParents(prismaNew, ctx, "hydrate");
// All four runs land on the legacy DB (legacy + replication source -> CH gets the full id-set).
const legacyOnlyA = await createRun(prisma, ctx, { friendlyId: "run_legacyA" });
const legacyOnlyB = await createRun(prisma, ctx, { friendlyId: "run_legacyB" });
const migratedA = await createRun(prisma, ctx, { friendlyId: "run_newA" });
const migratedB = await createRun(prisma, ctx, { friendlyId: "run_newB" });
// The two "migrated" runs also live on NEW (authoritative during retention), same ids +
// friendlyIds, but a DISTINGUISHING taskIdentifier so a row served from the new DB is
// identifiable: "my-task-NEW" here vs the default "my-task" on the legacy DB.
await createRun(prismaNew, ctx, { friendlyId: "run_newA", taskIdentifier: "my-task-NEW" });
await createRun(prismaNew, ctx, { friendlyId: "run_newB", taskIdentifier: "my-task-NEW" });
await prismaNew.taskRun.update({
where: { friendlyId: "run_newA" },
data: { id: migratedA.id },
});
await prismaNew.taskRun.update({
where: { friendlyId: "run_newB" },
data: { id: migratedB.id },
});
// Wait for CH replication so the id-set page is non-empty.
await setTimeout(1500);
const presenter = new NextRunListPresenter(prisma, clickhouse, {
newClient: prismaNew,
legacyReplica: prisma,
splitEnabled: true,
});
const result = await presenter.call(
ctx.organizationId,
ctx.environmentId,
callOptions(ctx)
);
const expectedIds = [migratedA.id, migratedB.id, legacyOnlyA.id, legacyOnlyB.id].sort(
(a, b) => (a < b ? 1 : a > b ? -1 : 0)
);
expect(result.runs.map((r) => r.id)).toEqual(expectedIds);
// The migrated rows must carry the new-DB-only taskIdentifier — this can only hold if they
// were hydrated from the threaded newClient (new DB), proving the routed store used it.
expect(result.runs.find((r) => r.id === migratedA.id)?.friendlyId).toBe("run_newA");
expect(result.runs.find((r) => r.id === migratedA.id)?.taskIdentifier).toBe("my-task-NEW");
expect(result.runs.find((r) => r.id === migratedB.id)?.taskIdentifier).toBe("my-task-NEW");
// The legacy-only rows surface from the legacy DB with the legacy taskIdentifier — proving the
// legacyReplica (legacy DB) is also exercised for ids absent from the new DB.
expect(result.runs.find((r) => r.id === legacyOnlyA.id)?.friendlyId).toBe("run_legacyA");
expect(result.runs.find((r) => r.id === legacyOnlyA.id)?.taskIdentifier).toBe("my-task");
expect(result.runs.find((r) => r.id === legacyOnlyB.id)?.taskIdentifier).toBe("my-task");
// Non-empty page -> the empty-state probe is not consulted, but it's still true.
expect(result.hasAnyRuns).toBe(true);
} finally {
await prismaNew.$disconnect();
}
}
);
});
@@ -1,97 +0,0 @@
// Run-ops split resolution LOCK for ApiBatchResultsPresenter.
//
// GET /api/v1/batches/:id/results constructs the presenter BARE (no injected client), so it must
// resolve a batch that lives in the NEW run-ops DB on its own. The presenter routes the batch-row
// lookup through the `runStore` singleton, whose split router probes NEW→LEGACY. This drives a
// NEW-resident (ksuid) batch through a REAL two-physical-DB split router and asserts the bare
// presenter finds it. Fails before the fix (the presenter read the control-plane DB directly and
// 404'd on a NEW-resident batch).
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import type { Organization, PrismaClient, Project } from "@trigger.dev/database";
import { generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
import { expect, vi } from "vitest";
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
// The split router built over the two testcontainer DBs; injected in place of the db.server-backed
// singleton the presenter imports. Populated per-test before the presenter is constructed.
let testRunStore: RoutingRunStore;
// Presenter reads the batch row via `runStore`; child-run reads also go through it. Neutralize the
// real db.server singleton (no env DB) and the runStore singleton (use the split router below).
// The getter defers to `testRunStore` so each test can set its own router before constructing.
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
vi.mock("~/v3/runStore.server", () => ({
get runStore() {
return testRunStore;
},
}));
vi.setConfig({ testTimeout: 60_000 });
function makeSplitRouter(prisma14: PrismaClient, prisma17: RunOpsPrismaClient) {
const legacyStore = new PostgresRunStore({
prisma: prisma14,
readOnlyPrisma: prisma14,
schemaVariant: "legacy",
});
const newStore = new PostgresRunStore({
prisma: prisma17 as never,
readOnlyPrisma: prisma17 as never,
schemaVariant: "dedicated",
});
return new RoutingRunStore({ new: newStore, legacy: legacyStore });
}
function authEnv(environmentId: string): AuthenticatedEnvironment {
return {
id: environmentId,
type: "DEVELOPMENT",
project: { id: "proj_split" } as Project,
organization: { id: "org_split" } as Organization,
orgMember: null,
} as unknown as AuthenticatedEnvironment;
}
heteroRunOpsPostgresTest(
"a bare ApiBatchResultsPresenter resolves a NEW-resident (ksuid) batch under the split",
async ({ prisma14, prisma17 }) => {
testRunStore = makeSplitRouter(prisma14, prisma17);
const environmentId = "env_split_res";
// ksuid internal id → classifies to the NEW store, seeded in the NEW (prisma17) DB. The
// friendlyId probe fans out NEW→LEGACY regardless of id shape, so the NEW seed is what matters.
const batchInternalId = generateKsuidId();
const batchFriendlyId = `batch_${generateKsuidId()}`;
await prisma17.batchTaskRun.create({
data: {
id: batchInternalId,
friendlyId: batchFriendlyId,
runtimeEnvironmentId: environmentId,
},
});
// Bare construction — exactly how the results route builds it.
const presenter = new ApiBatchResultsPresenter();
const result = await presenter.call(batchFriendlyId, authEnv(environmentId));
// Before the fix this 404s (undefined) because a control-plane read misses the NEW-resident batch.
expect(result).toEqual({ id: batchFriendlyId, items: [] });
}
);
heteroRunOpsPostgresTest(
"a bare ApiBatchResultsPresenter still returns undefined for a genuinely missing batch",
async ({ prisma14, prisma17 }) => {
testRunStore = makeSplitRouter(prisma14, prisma17);
const presenter = new ApiBatchResultsPresenter();
const result = await presenter.call("batch_does_not_exist", authEnv("env_none"));
expect(result).toBeUndefined();
}
);
@@ -177,7 +177,9 @@ containerTest(
});
}
const presenter = new ApiBatchResultsPresenter(prisma);
// Pass the testcontainer prisma as both the primary and the replica: the passthrough path
// reads the batch row off the replica and the member runs off the primary (via the run store).
const presenter = new ApiBatchResultsPresenter(prisma, prisma);
const result = await presenter.call(
batchFriendlyId,
authEnv(environment, project, organization)
@@ -243,7 +245,9 @@ containerTest(
});
}
const presenter = new ApiBatchResultsPresenter(prisma);
// Pass the testcontainer prisma as both the primary and the replica: the passthrough path
// reads the batch row off the replica and the member runs off the primary (via the run store).
const presenter = new ApiBatchResultsPresenter(prisma, prisma);
const result = await presenter.call(
batchFriendlyId,
authEnv(environment, project, organization)
@@ -267,7 +271,9 @@ containerTest("ApiBatchResultsPresenter short-circuits an empty batch", async ({
},
});
const presenter = new ApiBatchResultsPresenter(prisma);
// Pass the testcontainer prisma as both the primary and the replica: the passthrough path
// reads the batch row off the replica and the member runs off the primary (via the run store).
const presenter = new ApiBatchResultsPresenter(prisma, prisma);
const result = await presenter.call(batchFriendlyId, authEnv(environment, project, organization));
expect(result).toEqual({ id: batchFriendlyId, items: [] });
@@ -0,0 +1,157 @@
// SPLIT-NEUTRAL VERIFICATION: getActivity reads ClickHouse
// (task_runs_v2) ONLY and never touches the run-ops Prisma client. A
// heterogeneous legacy/new Postgres fixture is deliberately not applicable
// here — there is no run-ops Postgres read to validate cross-version. The
// throwing Proxy passed as `replica` proves it: any access throws.
import { describe, expect, vi } from "vitest";
import { clickhouseTest } from "@internal/testcontainers";
import { ClickHouse, type TaskRunV2 } from "@internal/clickhouse";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { TaskDetailPresenter } from "~/presenters/v3/TaskDetailPresenter.server";
vi.setConfig({ testTimeout: 60_000 });
const organizationId = "org_activity_test";
const projectId = "project_activity_test";
const environmentId = "env_activity_test";
const taskSlug = "my-activity-task";
function makeRun(overrides: Partial<TaskRunV2>): TaskRunV2 {
const createdAt = overrides.created_at ?? Date.now();
return {
environment_id: environmentId,
organization_id: organizationId,
project_id: projectId,
run_id: `run_${randomUUID()}`,
friendly_id: `friendly_${randomUUID()}`,
updated_at: createdAt,
created_at: createdAt,
status: "COMPLETED_SUCCESSFULLY",
environment_type: "PRODUCTION",
attempt: 1,
engine: "V2",
task_identifier: taskSlug,
queue: "my-queue",
schedule_id: "",
batch_id: "",
task_version: "",
sdk_version: "",
cli_version: "",
machine_preset: "",
root_run_id: "",
parent_run_id: "",
span_id: "",
trace_id: "",
idempotency_key: "",
expiration_ttl: "",
_version: "1",
_is_deleted: 0,
...overrides,
};
}
describe("TaskDetailPresenter.getActivity (ClickHouse-only)", () => {
clickhouseTest(
"buckets task_runs_v2 activity by status group, excludes deleted, never reads Postgres",
async ({ clickhouseContainer }) => {
const clickhouse = new ClickHouse({
url: clickhouseContainer.getConnectionUrl(),
name: "task-detail-activity-test",
compression: { request: true },
});
const insert = clickhouse.writer.insert({
name: "insertTaskRunsActivityTest",
table: "trigger_dev.task_runs_v2",
schema: z.any(),
settings: { async_insert: 0, enable_json_type: 1, type_json_skip_duplicated_paths: 1 },
});
// 6h window => 300s (5-minute) buckets => 72 buckets (chooseBucketSeconds targets ~72).
const from = new Date("2026-01-01T00:00:00Z");
const to = new Date("2026-01-01T06:00:00Z");
const BUCKET_MS = 5 * 60 * 1000;
// 00:30 bucket: 1 COMPLETED, 1 FAILED (+ 1 deleted, excluded).
// 02:30 bucket: 1 CANCELED, RUNNING = EXECUTING + unknown-status = 2.
const bucket0 = new Date("2026-01-01T00:30:00Z").getTime();
const bucket2 = new Date("2026-01-01T02:30:00Z").getTime();
const rows = [
makeRun({ created_at: bucket0, status: "COMPLETED_SUCCESSFULLY" }),
makeRun({ created_at: bucket0, status: "CRASHED" }), // FAILED group
makeRun({ created_at: bucket2, status: "CANCELED" }), // CANCELED group
makeRun({ created_at: bucket2, status: "EXECUTING" }), // RUNNING group
makeRun({ created_at: bucket2, status: "SOME_UNKNOWN_STATUS" }), // folds into RUNNING
// Deleted row — distinct run, _is_deleted = 1, must NOT be counted.
makeRun({ created_at: bucket0, status: "COMPLETED_SUCCESSFULLY", _is_deleted: 1 }),
];
const [insertError] = await insert(rows);
expect(insertError).toBeNull();
const throwingReplica = new Proxy(
{},
{
get() {
throw new Error("getActivity must not touch the run-ops Prisma client");
},
}
) as never;
const presenter = new TaskDetailPresenter(throwingReplica, clickhouse);
const activity = await presenter.getActivity({
organizationId,
projectId,
environmentId,
taskSlug,
from,
to,
});
// Stable legend, fixed group order.
expect(activity.statuses).toEqual(["COMPLETED", "FAILED", "CANCELED", "RUNNING"]);
// 72 five-minute buckets, every bucket carries all four group keys.
expect(activity.data).toHaveLength(72);
for (const point of activity.data) {
expect(typeof point.bucket).toBe("number");
expect(point).toHaveProperty("COMPLETED");
expect(point).toHaveProperty("FAILED");
expect(point).toHaveProperty("CANCELED");
expect(point).toHaveProperty("RUNNING");
}
// Buckets are epoch MILLISECONDS aligned to the 5-minute interval.
const byBucket = new Map(activity.data.map((p) => [p.bucket, p]));
const p0 = byBucket.get(Math.floor(bucket0 / BUCKET_MS) * BUCKET_MS)!;
const p2 = byBucket.get(Math.floor(bucket2 / BUCKET_MS) * BUCKET_MS)!;
expect(p0).toBeDefined();
expect(p2).toBeDefined();
// 00:30 bucket: 1 COMPLETED, 1 FAILED, deleted row excluded.
expect(p0.COMPLETED).toBe(1);
expect(p0.FAILED).toBe(1);
expect(p0.CANCELED).toBe(0);
expect(p0.RUNNING).toBe(0);
// 02:30 bucket: 1 CANCELED, RUNNING = EXECUTING (1) + unknown status (1) = 2.
expect(p2.COMPLETED).toBe(0);
expect(p2.FAILED).toBe(0);
expect(p2.CANCELED).toBe(1);
expect(p2.RUNNING).toBe(2);
// Every other bucket is all-zero for every group.
for (const point of activity.data) {
if (point.bucket === p0.bucket || point.bucket === p2.bucket) continue;
expect(point.COMPLETED).toBe(0);
expect(point.FAILED).toBe(0);
expect(point.CANCELED).toBe(0);
expect(point.RUNNING).toBe(0);
}
}
);
});
@@ -0,0 +1,514 @@
import { describe, expect, vi } from "vitest";
// The presenter module graph imports `~/v3/runStore.server`, which imports `~/db.server`
// at load. Stub it (the sibling runsRepository.readthrough.test.ts does the same) — the
// presenter under test is driven entirely through injected real containers, never the
// stubbed module singletons.
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
}));
import { PostgresRunStore } from "@internal/run-store";
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
import { PrismaClient } from "@trigger.dev/database";
import { setTimeout } from "node:timers/promises";
import superjson from "superjson";
import { TestTaskPresenter } from "~/presenters/v3/TestTaskPresenter.server";
import { setupClickhouseReplication } from "../utils/replicationUtils";
vi.setConfig({ testTimeout: 90_000 });
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
};
const JSON_TYPE = "application/json";
const SUPERJSON_TYPE = "application/super+json";
/**
* Creates the org/project/(DEVELOPMENT) env parents plus the BackgroundWorker +
* BackgroundWorkerTask the presenter resolves the task from. TaskRun FKs require
* the org/project/env to exist on every DB a run is hydrated from.
*/
async function seedParents(
prisma: PrismaClient,
slug: string,
triggerSource: "STANDARD" | "SCHEDULED"
): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${slug}`,
pkApiKey: `pk_dev_${slug}`,
shortcode: `sc-${slug}`,
},
});
const worker = await prisma.backgroundWorker.create({
data: {
friendlyId: `worker_${slug}`,
contentHash: `hash-${slug}`,
version: "20240101.1",
engine: "V2",
metadata: {},
projectId: project.id,
runtimeEnvironmentId: runtimeEnvironment.id,
},
});
await prisma.backgroundWorkerTask.create({
data: {
friendlyId: `task_${slug}`,
slug: "my-task",
filePath: "src/trigger/my-task.ts",
exportName: "myTask",
workerId: worker.id,
projectId: project.id,
runtimeEnvironmentId: runtimeEnvironment.id,
triggerSource,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: runtimeEnvironment.id,
};
}
/** Mirrors the org/project/env parents onto a second DB with the SAME ids. */
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
await prisma.organization.create({
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: ctx.projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: ctx.organizationId,
externalRef: `proj-${slug}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environmentId,
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: ctx.projectId,
organizationId: ctx.organizationId,
apiKey: `tr_dev_${slug}_b`,
pkApiKey: `pk_dev_${slug}_b`,
shortcode: `sc-${slug}-b`,
},
});
}
async function createRun(
prisma: PrismaClient,
ctx: SeedContext,
run: {
friendlyId: string;
payload: string;
payloadType?: string;
createdAt?: Date;
runTags?: string[];
}
) {
return prisma.taskRun.create({
data: {
friendlyId: run.friendlyId,
taskIdentifier: "my-task",
status: "COMPLETED_SUCCESSFULLY",
payload: run.payload,
payloadType: run.payloadType ?? JSON_TYPE,
traceId: run.friendlyId,
spanId: run.friendlyId,
queue: "task/my-task",
runTags: run.runTags ?? [],
runtimeEnvironmentId: ctx.environmentId,
projectId: ctx.projectId,
organizationId: ctx.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
...(run.createdAt ? { createdAt: run.createdAt } : {}),
},
});
}
/** Copy a row created on one DB onto another DB with the SAME id. */
async function copyRunWithId(
prisma: PrismaClient,
ctx: SeedContext,
source: { id: string; friendlyId: string; payload: string; payloadType: string; createdAt: Date }
) {
const created = await createRun(prisma, ctx, {
friendlyId: source.friendlyId,
payload: source.payload,
payloadType: source.payloadType,
createdAt: source.createdAt,
});
await prisma.taskRun.update({
where: { friendlyId: source.friendlyId },
data: { id: source.id },
});
return created;
}
function envFor(ctx: SeedContext) {
return {
id: ctx.environmentId,
type: "DEVELOPMENT" as const,
projectId: ctx.projectId,
organizationId: ctx.organizationId,
};
}
/** A legacy-replica handle whose taskRun.findMany throws — proves it is never hydrated from. */
function throwingLegacyReplica(prisma: PrismaClient): PrismaClient {
return new Proxy(prisma, {
get(target, prop) {
if (prop === "taskRun") {
return new Proxy((target as any).taskRun, {
get(trTarget, trProp) {
if (trProp === "findMany") {
return async () => {
throw new Error("legacy replica hydrate must not be invoked");
};
}
return (trTarget as any)[trProp];
},
});
}
return (target as any)[prop];
},
}) as unknown as PrismaClient;
}
describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new)", () => {
// payloadType parity: split union of NEW + legacy-replica, JSON-only, createdAt desc.
replicationContainerTest(
"split mode hydrates the 10-most-recent CH id-set as the union of NEW + legacy-replica rows, payloadType filtered, createdAt desc",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
// PG14 = legacy read replica AND the replication source feeding the CH id-set.
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
try {
const ctx = await seedParents(prisma, "split1", "STANDARD");
await mirrorParents(prismaNew, ctx, "split1");
const base = Date.now();
const at = (offsetMs: number) => new Date(base - offsetMs);
// All rows seeded on PG14 (legacy + replication source -> CH gets the full id-set).
const legacyOld = await createRun(prisma, ctx, {
friendlyId: "run_legacy_old",
payload: JSON.stringify({ kind: "legacy-old" }),
createdAt: at(4000),
});
const nonJson = await createRun(prisma, ctx, {
friendlyId: "run_nonjson",
payload: "binary-bytes",
payloadType: "application/octet-stream",
createdAt: at(3000),
});
const migratedSuper = await createRun(prisma, ctx, {
friendlyId: "run_migrated_super",
payload: JSON.stringify({ kind: "migrated-super" }),
payloadType: SUPERJSON_TYPE,
createdAt: at(2000),
});
const migratedJson = await createRun(prisma, ctx, {
friendlyId: "run_migrated_json",
payload: JSON.stringify({ kind: "migrated-json" }),
createdAt: at(1000),
});
// The two "migrated" runs ALSO live on NEW (PG17), authoritative during retention.
await copyRunWithId(prismaNew, ctx, {
id: migratedSuper.id,
friendlyId: migratedSuper.friendlyId,
payload: migratedSuper.payload,
payloadType: SUPERJSON_TYPE,
createdAt: migratedSuper.createdAt,
});
await copyRunWithId(prismaNew, ctx, {
id: migratedJson.id,
friendlyId: migratedJson.friendlyId,
payload: migratedJson.payload,
payloadType: JSON_TYPE,
createdAt: migratedJson.createdAt,
});
await setTimeout(1500);
const presenter = new TestTaskPresenter(
prisma,
clickhouse,
{
splitEnabled: true,
newClient: prismaNew,
legacyReplica: prisma,
},
new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew })
);
const result = await presenter.call({
userId: "user_1",
projectId: ctx.projectId,
environment: envFor(ctx),
taskIdentifier: "my-task",
});
expect(result.foundTask).toBe(true);
if (!result.foundTask || result.triggerSource !== "STANDARD") {
throw new Error("expected a STANDARD task");
}
// Union of the JSON/super+json rows across both DBs, createdAt desc, non-JSON absent.
expect(result.runs.map((r) => r.friendlyId)).toEqual([
"run_migrated_json",
"run_migrated_super",
"run_legacy_old",
]);
expect(result.runs.map((r) => r.friendlyId)).not.toContain("run_nonjson");
expect(result.runs.find((r) => r.id === nonJson.id)).toBeUndefined();
// payloadType round-trips byte-identically across PG14/PG17.
expect(result.runs.find((r) => r.id === migratedSuper.id)!.payloadType).toBe(
SUPERJSON_TYPE
);
expect(result.runs.find((r) => r.id === migratedJson.id)!.payloadType).toBe(JSON_TYPE);
expect(result.runs.find((r) => r.id === legacyOld.id)!.payloadType).toBe(JSON_TYPE);
} finally {
await prismaNew.$disconnect();
}
}
);
// Old in-retention run served from the legacy READ REPLICA only (no legacyWriter field exists).
replicationContainerTest(
"an in-retention legacy-only run hydrates from the legacy replica handle",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
try {
const ctx = await seedParents(prisma, "legacyonly", "STANDARD");
await mirrorParents(prismaNew, ctx, "legacyonly");
const legacyOnly = await createRun(prisma, ctx, {
friendlyId: "run_legacy_only",
payload: JSON.stringify({ kind: "legacy-only" }),
});
await setTimeout(1500);
// The deps shape exposes only `legacyReplica` — there is no `legacyWriter`/primary
// field, so the legacy primary is structurally unreachable from this hydrate.
const presenter = new TestTaskPresenter(
prisma,
clickhouse,
{
splitEnabled: true,
newClient: prismaNew,
legacyReplica: prisma,
},
new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew })
);
const result = await presenter.call({
userId: "user_1",
projectId: ctx.projectId,
environment: envFor(ctx),
taskIdentifier: "my-task",
});
if (!result.foundTask || result.triggerSource !== "STANDARD") {
throw new Error("expected a STANDARD task");
}
expect(result.runs.map((r) => r.id)).toEqual([legacyOnly.id]);
} finally {
await prismaNew.$disconnect();
}
}
);
// Passthrough (single-DB): one plain store read, the legacy replica never touched.
replicationContainerTest(
"single-DB passthrough hydrates from one store read and never touches the legacy replica",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const ctx = await seedParents(prisma, "passthrough", "STANDARD");
const base = Date.now();
const newer = await createRun(prisma, ctx, {
friendlyId: "run_newer",
payload: JSON.stringify({ n: 2 }),
createdAt: new Date(base - 1000),
});
const older = await createRun(prisma, ctx, {
friendlyId: "run_older",
payload: JSON.stringify({ n: 1 }),
createdAt: new Date(base - 2000),
});
await createRun(prisma, ctx, {
friendlyId: "run_nonjson",
payload: "bytes",
payloadType: "application/octet-stream",
createdAt: new Date(base - 500),
});
await setTimeout(1500);
// No readThrough (split off). Inject a throwing legacy replica to prove the split branch
// is never entered: a runStore whose findRuns drives the single `prisma` handle.
const presenter = new TestTaskPresenter(
prisma,
clickhouse,
{
splitEnabled: false,
legacyReplica: throwingLegacyReplica(prisma),
},
new PostgresRunStore({ prisma, readOnlyPrisma: prisma })
);
const result = await presenter.call({
userId: "user_1",
projectId: ctx.projectId,
environment: envFor(ctx),
taskIdentifier: "my-task",
});
if (!result.foundTask || result.triggerSource !== "STANDARD") {
throw new Error("expected a STANDARD task");
}
// createdAt desc, JSON-only.
expect(result.runs.map((r) => r.id)).toEqual([newer.id, older.id]);
}
);
// SCHEDULED-source parity: same hydrate path, ScheduledRun mapping exercised.
replicationContainerTest(
"SCHEDULED task: split union parses to ScheduledRun shape identically to single-DB",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
try {
const ctx = await seedParents(prisma, "scheduled", "SCHEDULED");
await mirrorParents(prismaNew, ctx, "scheduled");
// super+json so parsePacket revives the Date fields the ScheduledTaskPayload schema requires.
const schedulePayload = superjson.stringify({
scheduleId: "sched_1",
type: "IMPERATIVE",
timestamp: new Date("2026-01-01T00:00:00.000Z"),
timezone: "UTC",
externalId: "ext-1",
upcoming: [new Date("2026-01-02T00:00:00.000Z")],
});
const base = Date.now();
const migrated = await createRun(prisma, ctx, {
friendlyId: "run_sched_new",
payload: schedulePayload,
payloadType: SUPERJSON_TYPE,
createdAt: new Date(base - 1000),
});
await copyRunWithId(prismaNew, ctx, {
id: migrated.id,
friendlyId: migrated.friendlyId,
payload: schedulePayload,
payloadType: SUPERJSON_TYPE,
createdAt: migrated.createdAt,
});
const legacy = await createRun(prisma, ctx, {
friendlyId: "run_sched_legacy",
payload: schedulePayload,
payloadType: SUPERJSON_TYPE,
createdAt: new Date(base - 2000),
});
await setTimeout(1500);
const presenter = new TestTaskPresenter(
prisma,
clickhouse,
{
splitEnabled: true,
newClient: prismaNew,
legacyReplica: prisma,
},
new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew })
);
const result = await presenter.call({
userId: "user_1",
projectId: ctx.projectId,
environment: envFor(ctx),
taskIdentifier: "my-task",
});
if (!result.foundTask || result.triggerSource !== "SCHEDULED") {
throw new Error("expected a SCHEDULED task");
}
expect(result.runs.map((r) => r.id)).toEqual([migrated.id, legacy.id]);
// Parsed ScheduledRun payload shape.
expect(result.runs[0].payload.timezone).toBe("UTC");
expect(result.runs[0].payload.externalId).toBe("ext-1");
} finally {
await prismaNew.$disconnect();
}
}
);
});
@@ -0,0 +1,388 @@
import { describe, expect, vi } from "vitest";
// The runsRepository module graph imports `~/v3/runStore.server`, which imports `~/db.server`
// at load. Stub it (the existing runsRepository.part*.test.ts / readthrough test do the same) — the
// resolver under test is driven entirely through injected real containers, never the stubbed
// module singletons.
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
}));
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
import { PrismaClient } from "@trigger.dev/database";
import { setTimeout } from "node:timers/promises";
import { ClickHouseRunListResolver } from "~/services/realtime/clickHouseRunListResolver.server";
import { setupClickhouseReplication } from "../utils/replicationUtils";
vi.setConfig({ testTimeout: 90_000 });
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
};
/**
* Creates the org/project/env parents on a single prisma client. TaskRun FKs require these to
* exist, and this container doubles as the logical-replication source that feeds the
* ClickHouse id-set, so all runs whose ids we expect from ClickHouse are seeded here.
*/
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${slug}`,
pkApiKey: `pk_dev_${slug}`,
shortcode: `sc-${slug}`,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: runtimeEnvironment.id,
};
}
/** A second environment in the same project — used to prove the CH filter excludes other envs. */
async function seedSecondEnvironment(prisma: PrismaClient, ctx: SeedContext, slug: string) {
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}-2`,
type: "PRODUCTION",
projectId: ctx.projectId,
organizationId: ctx.organizationId,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `sc-${slug}-2`,
},
});
return runtimeEnvironment.id;
}
async function createRun(
prisma: PrismaClient,
ctx: SeedContext & { environmentId?: string },
run: { friendlyId: string; runTags?: string[]; createdAt?: Date }
) {
return prisma.taskRun.create({
data: {
friendlyId: run.friendlyId,
taskIdentifier: "my-task",
status: "PENDING",
payload: JSON.stringify({ foo: run.friendlyId }),
traceId: run.friendlyId,
spanId: run.friendlyId,
queue: "test",
runTags: run.runTags ?? [],
...(run.createdAt ? { createdAt: run.createdAt } : {}),
runtimeEnvironmentId: ctx.environmentId,
projectId: ctx.projectId,
organizationId: ctx.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
}
/**
* Wraps a real prisma client so ONLY `taskRun.findMany` throws every other member stays a real
* handle. The resolver's id-set path (`listRunIds` -> `listRunRows`) performs no `taskRun.findMany`
* and never calls the run-ops store, so this proxy must never trip. The CPRES-owned filter
* resolution that DOES run for a `batchId` filter uses `batchTaskRun.findFirst`, which this proxy
* leaves intact.
*/
function throwingTaskRunFindMany(prisma: PrismaClient): PrismaClient {
return new Proxy(prisma, {
get(target, prop) {
if (prop === "taskRun") {
return new Proxy((target as any).taskRun, {
get(trTarget, trProp) {
if (trProp === "findMany") {
return async () => {
throw new Error(
"taskRun.findMany must not be invoked on the realtime id-set path (a hydrate leaked in)"
);
};
}
return (trTarget as any)[trProp];
},
});
}
return (target as any)[prop];
},
}) as unknown as PrismaClient;
}
describe("ClickHouseRunListResolver (realtime run-list id-set, split-neutral)", () => {
// resolves the CH id-set with NO TaskRun PG hydrate.
replicationContainerTest(
"resolves the ClickHouse id-set for run-ops rows without ever reading TaskRun in Postgres",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const ctx = await seedParents(prisma, "idset");
const runA = await createRun(prisma, ctx, { friendlyId: "run_idsetA" });
const runB = await createRun(prisma, ctx, { friendlyId: "run_idsetB" });
const runC = await createRun(prisma, ctx, { friendlyId: "run_idsetC" });
await setTimeout(1500);
// ONLY taskRun.findMany throws; the rest of the client is real so the resolver can run.
const resolver = new ClickHouseRunListResolver({
getClickhouse: async () => clickhouse,
prisma: throwingTaskRunFindMany(prisma),
});
const runIds = await resolver.resolveMatchingRunIds({
organizationId: ctx.organizationId,
projectId: ctx.projectId,
environmentId: ctx.environmentId,
limit: 10,
});
// Asserting as a set: equal createdAt makes the CH (created_at, run_id) DESC tie-break the
// only ordering signal. The throwing proxy never tripped -> no TaskRun hydrate on this path.
expect([...runIds].sort()).toEqual([runA.id, runB.id, runC.id].sort());
}
);
// CH filter is split-neutral (ids independent of PG residency).
replicationContainerTest(
"returns the same id-set regardless of which Postgres the rows are hydrated from (CH-only path)",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
// A second, unrelated NEW client carrying NO rows. The id-set path never touches it;
// pointing the resolver at it must not change the result, proving the ids come from CH only.
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
try {
const ctx = await seedParents(prisma, "neutral");
const runA = await createRun(prisma, ctx, { friendlyId: "run_neutralA" });
const runB = await createRun(prisma, ctx, { friendlyId: "run_neutralB" });
await setTimeout(1500);
const filter = {
organizationId: ctx.organizationId,
projectId: ctx.projectId,
environmentId: ctx.environmentId,
limit: 10,
};
// "single-DB" wiring: resolver's prisma is the replication-source DB (where rows live).
const singleDb = new ClickHouseRunListResolver({
getClickhouse: async () => clickhouse,
prisma,
});
// "split" wiring: resolver's prisma is the empty NEW DB. If the id-set path read TaskRun
// from this handle the result would differ; it must not.
const split = new ClickHouseRunListResolver({
getClickhouse: async () => clickhouse,
prisma: prismaNew,
});
const idsSingleDb = await singleDb.resolveMatchingRunIds(filter);
const idsSplit = await split.resolveMatchingRunIds(filter);
expect(idsSplit).toEqual(idsSingleDb);
expect([...idsSingleDb].sort()).toEqual([runA.id, runB.id].sort());
} finally {
await prismaNew.$disconnect();
}
}
);
// single-DB passthrough; no legacy/known-migrated probe on this path.
replicationContainerTest(
"single-DB passthrough returns the CH id-set and never hydrates TaskRun",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const ctx = await seedParents(prisma, "passthrough");
const run = await createRun(prisma, ctx, { friendlyId: "run_passthrough" });
await setTimeout(1500);
const resolver = new ClickHouseRunListResolver({
getClickhouse: async () => clickhouse,
prisma: throwingTaskRunFindMany(prisma),
});
const runIds = await resolver.resolveMatchingRunIds({
organizationId: ctx.organizationId,
projectId: ctx.projectId,
environmentId: ctx.environmentId,
limit: 10,
});
expect(runIds).toEqual([run.id]);
}
);
// a far-future straggler's id surfaces from the CH id-set.
replicationContainerTest(
"surfaces a far-future delayed run's id from the CH id-set",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const ctx = await seedParents(prisma, "straggler");
const now = new Date();
const near = await createRun(prisma, ctx, { friendlyId: "run_near", createdAt: now });
// The migrated-by-sweep case: CH is residency-agnostic, so the id surfaces once indexed
// regardless of which DB holds the row.
const farFuture = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
const straggler = await createRun(prisma, ctx, {
friendlyId: "run_straggler",
createdAt: farFuture,
});
await setTimeout(1500);
const resolver = new ClickHouseRunListResolver({
getClickhouse: async () => clickhouse,
prisma,
});
const runIds = await resolver.resolveMatchingRunIds({
organizationId: ctx.organizationId,
projectId: ctx.projectId,
environmentId: ctx.environmentId,
limit: 10,
});
expect(runIds).toContain(straggler.id);
expect(runIds).toContain(near.id);
// (created_at, run_id) DESC ordering -> the far-future straggler sorts ahead of the near run.
expect(runIds.indexOf(straggler.id)).toBeLessThan(runIds.indexOf(near.id));
}
);
// tag match is contains-ALL (tagsMatch: "all" -> hasAll), authoritative.
// The sibling runReader.server.ts JSDoc still calls RunListFilter.tags "Contains-ANY"; that is
// stale. The resolver passes tagsMatch: "all" and the live CH repo maps
// it to hasAll, so contains-ALL is the real behavior — assert that, not the JSDoc.
replicationContainerTest(
"tag filter is contains-ALL: only runs carrying every requested tag are returned",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const ctx = await seedParents(prisma, "tags");
// Has BOTH requested tags -> matches contains-ALL.
const both = await createRun(prisma, ctx, {
friendlyId: "run_bothTags",
runTags: ["alpha", "beta"],
});
// Has only one of the requested tags -> excluded under contains-ALL (would match contains-ANY).
await createRun(prisma, ctx, { friendlyId: "run_oneTag", runTags: ["alpha"] });
// Has neither -> excluded.
await createRun(prisma, ctx, { friendlyId: "run_otherTag", runTags: ["gamma"] });
await setTimeout(1500);
const resolver = new ClickHouseRunListResolver({
getClickhouse: async () => clickhouse,
prisma,
});
const runIds = await resolver.resolveMatchingRunIds({
organizationId: ctx.organizationId,
projectId: ctx.projectId,
environmentId: ctx.environmentId,
tags: ["alpha", "beta"],
limit: 10,
});
// contains-ALL: only the run with BOTH tags. (contains-ANY would also return run_oneTag.)
expect(runIds).toEqual([both.id]);
}
);
// environment scoping: the CH filter excludes other environments.
// Doubles as a structural proof that an accidental hydrate would NOT change the id-set: rows on a
// different env are not returned because CH filters by environment_id, not because PG was read.
replicationContainerTest(
"scopes the id-set to the filtered environment (other-env rows are excluded by the CH filter)",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const ctx = await seedParents(prisma, "envscope");
const otherEnvId = await seedSecondEnvironment(prisma, ctx, "envscope");
const inEnv = await createRun(prisma, ctx, { friendlyId: "run_inEnv" });
await createRun(
prisma,
{ ...ctx, environmentId: otherEnvId },
{ friendlyId: "run_otherEnv" }
);
await setTimeout(1500);
const resolver = new ClickHouseRunListResolver({
getClickhouse: async () => clickhouse,
prisma: throwingTaskRunFindMany(prisma),
});
const runIds = await resolver.resolveMatchingRunIds({
organizationId: ctx.organizationId,
projectId: ctx.projectId,
environmentId: ctx.environmentId,
limit: 10,
});
expect(runIds).toEqual([inEnv.id]);
}
);
});
@@ -0,0 +1,322 @@
// Real-PG proof for the RunPresenter run-detail read seam. The DB is never mocked:
// the only vi.mock redirects the `~/db.server` module handle (captured by the
// `runStore` singleton at load) to the live testcontainer client via a delegating
// Proxy. Seeding `logsDeletedAt` + `showDeletedLogs: false` makes `showLogs` false,
// so the presenter returns the header EARLY — before the trace path and the
// `user.findFirst` admin read — keeping the test off ClickHouse.
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
import { describe, expect, vi } from "vitest";
vi.setConfig({ testTimeout: 60_000 });
// Hoisted alongside the vi.mock factory. `setCurrentPrisma` points the delegating
// Proxy at each test's container. The RunStore singleton is built ONCE at import and
// its error-normalizing wrapper memoizes each Prisma model delegate on first access;
// returning `current.taskRun` directly would freeze the store onto the first test's
// container ("Database test_0 does not exist" on later tests). So object-valued
// delegates return a STABLE per-key sub-proxy the store can safely cache, which
// re-delegates to the live `current[key]` on every access; functions/scalars pass
// through to the live client.
const { delegating, setCurrentPrisma } = vi.hoisted(() => {
let current: any = undefined;
const subProxyCache = new Map<string, unknown>();
const getSubProxy = (prop: string) => {
const cached = subProxyCache.get(prop);
if (cached) {
return cached;
}
const subProxy = new Proxy(
{},
{
get(_st, key) {
if (!current) {
throw new Error("currentPrisma not set");
}
const delegate = current[prop];
const value = delegate?.[key];
return typeof value === "function" ? value.bind(delegate) : value;
},
}
);
subProxyCache.set(prop, subProxy);
return subProxy;
};
const proxy = new Proxy(
{},
{
get(_t, prop) {
if (!current) {
throw new Error("currentPrisma not set");
}
if (typeof prop === "string") {
const value = current[prop];
if (value != null && typeof value === "object") {
return getSubProxy(prop);
}
return value;
}
return current[prop];
},
}
);
return {
delegating: proxy,
setCurrentPrisma: (p: unknown) => {
current = p;
},
};
});
vi.mock("~/db.server", () => ({
prisma: delegating,
$replica: delegating,
}));
// Imported AFTER the hoisted vi.mock so the singleton (built from `~/db.server`)
// captures the Proxy as its `readOnlyPrisma`.
import {
RunEnvironmentMismatchError,
RunNotInPgError,
RunPresenter,
} from "~/presenters/v3/RunPresenter.server";
let suffixCounter = 0;
function uniqueSuffix(prefix: string) {
suffixCounter += 1;
return `${prefix}-${suffixCounter}-${Date.now()}`;
}
async function seedOrgProjectEnvMember(prisma: PrismaClient, suffix: string) {
const user = await prisma.user.create({
data: {
email: `${suffix}@test.com`,
authenticationMethod: "MAGIC_LINK",
},
});
const organization = await prisma.organization.create({
data: {
title: `org-${suffix}`,
slug: `org-${suffix}`,
members: { create: { userId: user.id, role: "ADMIN" } },
},
include: { members: true },
});
const project = await prisma.project.create({
data: {
name: `proj-${suffix}`,
slug: `proj-${suffix}`,
organizationId: organization.id,
externalRef: `ext-${suffix}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${suffix}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
orgMemberId: organization.members[0]!.id,
apiKey: `tr_dev_${suffix}`,
pkApiKey: `pk_dev_${suffix}`,
shortcode: `sc-${suffix}`,
},
});
return { user, organization, project, runtimeEnvironment };
}
async function seedRun(
prisma: PrismaClient,
ids: { id: string; friendlyId: string },
env: { runtimeEnvironmentId: string; projectId: string; organizationId: string }
) {
return prisma.taskRun.create({
data: {
id: ids.id,
friendlyId: ids.friendlyId,
taskIdentifier: "my-task",
payload: JSON.stringify({ foo: "bar" }),
payloadType: "application/json",
traceId: "trace-1234",
spanId: "span-1234",
queue: "test",
runtimeEnvironmentId: env.runtimeEnvironmentId,
projectId: env.projectId,
organizationId: env.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
status: "COMPLETED_SUCCESSFULLY",
// logsDeletedAt set → showLogs is false → presenter returns the header
// EARLY, before the trace path and before the `user.findFirst` admin read.
logsDeletedAt: new Date(),
},
});
}
describe("RunPresenter run read seam (single-DB, real PG)", () => {
postgresTest(
"passthrough resolves the run-detail header via the singleton",
async ({ prisma }) => {
setCurrentPrisma(prisma);
const suffix = uniqueSuffix("pt");
const { user, organization, project, runtimeEnvironment } = await seedOrgProjectEnvMember(
prisma,
suffix
);
const id = generateKsuidId();
const friendlyId = `run_${id}`;
const run = await seedRun(
prisma,
{ id, friendlyId },
{
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
}
);
const presenter = new RunPresenter(prisma);
const result = await presenter.call({
userId: user.id,
projectSlug: project.slug,
environmentSlug: runtimeEnvironment.slug,
runFriendlyId: friendlyId,
showDeletedLogs: false,
showDebug: false,
});
// Header served through the store seam (singleton → Proxy → real container).
expect(result.run.id).toBe(id);
expect(result.run.friendlyId).toBe(friendlyId);
expect(result.run.number).toBe(run.number);
expect(result.run.status).toBe("COMPLETED_SUCCESSFULLY");
expect(result.run.environment.slug).toBe(runtimeEnvironment.slug);
// logsDeletedAt is set → early return, no trace.
expect(result.trace).toBeUndefined();
}
);
postgresTest(
"run read is NOT pinned to the constructor client (served via the seam)",
async ({ prisma }) => {
setCurrentPrisma(prisma);
const suffix = uniqueSuffix("unpinned");
const { user, organization, project, runtimeEnvironment } = await seedOrgProjectEnvMember(
prisma,
suffix
);
const id = generateKsuidId();
const friendlyId = `run_${id}`;
await seedRun(
prisma,
{ id, friendlyId },
{
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
}
);
// Sentinel constructor client whose taskRun.findFirst THROWS: if the run read
// were pinned to this.#prismaClient the call would reject — it resolves because
// the run read flows through the store seam/singleton instead. The control-plane
// lookups (project-scope + membership auth; the trace-path admin user.findFirst)
// are NOT split and correctly use the constructor client, so they are stubbed to
// succeed while taskRun stays throwing.
const sentinel = {
taskRun: {
findFirst: () => {
throw new Error("run read must not use constructor client");
},
},
project: {
findFirst: async () => ({ id: project.id }),
},
user: {
findFirst: async () => user,
},
} as unknown as PrismaClient;
const presenter = new RunPresenter(sentinel);
const result = await presenter.call({
userId: user.id,
projectSlug: project.slug,
environmentSlug: runtimeEnvironment.slug,
runFriendlyId: friendlyId,
showDeletedLogs: false,
showDebug: false,
});
expect(result.run.id).toBe(id);
expect(result.run.friendlyId).toBe(friendlyId);
expect(result.trace).toBeUndefined();
}
);
postgresTest("missing run maps to RunNotInPgError", async ({ prisma }) => {
setCurrentPrisma(prisma);
const suffix = uniqueSuffix("notfound");
const { user, project, runtimeEnvironment } = await seedOrgProjectEnvMember(prisma, suffix);
const missingFriendlyId = `run_${generateKsuidId()}`;
const presenter = new RunPresenter(prisma);
await expect(
presenter.call({
userId: user.id,
projectSlug: project.slug,
environmentSlug: runtimeEnvironment.slug,
runFriendlyId: missingFriendlyId,
showDeletedLogs: false,
showDebug: false,
})
).rejects.toThrow(RunNotInPgError);
});
postgresTest("environment mismatch maps to RunEnvironmentMismatchError", async ({ prisma }) => {
setCurrentPrisma(prisma);
const suffix = uniqueSuffix("mismatch");
const { user, organization, project, runtimeEnvironment } = await seedOrgProjectEnvMember(
prisma,
suffix
);
const id = generateKsuidId();
const friendlyId = `run_${id}`;
await seedRun(
prisma,
{ id, friendlyId },
{
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
}
);
const presenter = new RunPresenter(prisma);
await expect(
presenter.call({
userId: user.id,
projectSlug: project.slug,
// Seeded env slug is `env-${suffix}`; pass a different slug.
environmentSlug: `other-${suffix}`,
runFriendlyId: friendlyId,
showDeletedLogs: false,
showDebug: false,
})
).rejects.toThrow(RunEnvironmentMismatchError);
});
});
+16
View File
@@ -140,6 +140,22 @@ vi.mock("~/services/taskMetadataCacheInstance.server", async () => {
return { taskMetadataCacheInstance: new NoopTaskMetadataCache() };
});
// The org-data-stores registry singleton is constructed at import (transitively via
// the ClickHouse factory instance, which many presenters pull in). Its ctor fires a
// `forever` pRetry(loadFromDatabase) plus a setInterval reload against db.server's
// $replica; in CI (no Postgres) those retry forever, blocking the worker until any
// awaiting test's hook times out. Stub the instance to a no-op — no unit test uses
// this singleton (the registry-behavior tests construct the class directly).
vi.mock("~/services/dataStores/organizationDataStoresRegistryInstance.server", () => ({
organizationDataStoresRegistry: {
isReady: Promise.resolve(),
isLoaded: true,
get: vi.fn().mockReturnValue(null),
reload: vi.fn().mockResolvedValue(undefined),
loadFromDatabase: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("~/v3/runEngine.server", () => ({ engine: noopProxy() }));
vi.mock("~/v3/marqs/index.server", () => ({ marqs: noopProxy(), MarQS: class {} }));
vi.mock("~/v3/marqs/devPubSub.server", () => ({ devPubSub: noopProxy() }));
@@ -0,0 +1,151 @@
import { heteroPostgresTest } from "@internal/testcontainers";
import { PostgresRunStore } from "@internal/run-store";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { ControlPlaneCache } from "~/v3/runOpsMigration/controlPlaneCache.server";
import { ControlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
vi.setConfig({ testTimeout: 60_000 });
const TASK_RUN_CROSS_SEAM_FKS = [
"TaskRun_runtimeEnvironmentId_fkey",
"TaskRun_projectId_fkey",
"TaskRun_organizationId_fkey",
// lockedBy/lockedToVersion point at control-plane rows (BackgroundWorkerTask /
// BackgroundWorker) that live only on PG14 — drop their FKs on the PG17 run-ops DB.
"TaskRun_lockedById_fkey",
"TaskRun_lockedToVersionId_fkey",
] as const;
async function dropTaskRunCrossSeamFks(prisma: PrismaClient) {
for (const constraint of TASK_RUN_CROSS_SEAM_FKS) {
await prisma.$executeRawUnsafe(
`ALTER TABLE "TaskRun" DROP CONSTRAINT IF EXISTS "${constraint}"`
);
}
}
let n = 0;
async function seedControlPlaneWithWorker(prisma: PrismaClient) {
const s = n++;
const organization = await prisma.organization.create({
data: { title: `Org ${s}`, slug: `org-${s}` },
});
const project = await prisma.project.create({
data: {
name: `P ${s}`,
slug: `p-${s}`,
externalRef: `proj_${s}`,
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type: "PRODUCTION",
slug: `env-${s}`,
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_${s}`,
pkApiKey: `pk_${s}`,
shortcode: `sc_${s}`,
},
});
const worker = await prisma.backgroundWorker.create({
data: {
friendlyId: `worker_${s}`,
contentHash: `hash_${s}`,
projectId: project.id,
runtimeEnvironmentId: environment.id,
version: `2024.1.${s}`,
metadata: {},
engine: "V2",
sdkVersion: "3.0.0",
},
});
const task = await prisma.backgroundWorkerTask.create({
data: {
friendlyId: `task_${s}`,
slug: `t-${s}`,
filePath: "src/index.ts",
exportName: "myTask",
workerId: worker.id,
runtimeEnvironmentId: environment.id,
projectId: project.id,
},
});
return { organization, project, environment, worker, task };
}
describe("SpanPresenter cross-DB read-through", () => {
heteroPostgresTest(
"env + lockedToVersion + lockedBy resolve from PG14 while run scalars resolve from PG17",
async ({ prisma14, prisma17 }) => {
await dropTaskRunCrossSeamFks(prisma17 as unknown as PrismaClient);
const cp = await seedControlPlaneWithWorker(prisma14 as unknown as PrismaClient);
const _run = await (prisma17 as unknown as PrismaClient).taskRun.create({
data: {
id: `run_${n++}_pg17`,
engine: "V2",
status: "COMPLETED_SUCCESSFULLY",
friendlyId: `run_sp_${n}`,
runtimeEnvironmentId: cp.environment.id,
organizationId: cp.organization.id,
projectId: cp.project.id,
lockedById: cp.task.id,
lockedToVersionId: cp.worker.id,
taskIdentifier: "sp-task",
payload: "{}",
payloadType: "application/json",
queue: "task/sp-task",
traceId: "trace_sp",
spanId: "span_sp",
workerQueue: "main",
},
});
const runStore = new PostgresRunStore({
prisma: prisma17 as unknown as PrismaClient,
readOnlyPrisma: prisma17 as unknown as PrismaClient,
});
const resolver = new ControlPlaneResolver({
controlPlanePrimary: prisma14 as unknown as PrismaClient,
controlPlaneReplica: prisma14 as unknown as PrismaClient,
cache: new ControlPlaneCache(),
splitEnabled: () => false,
});
const found = await runStore.findRun(
{ spanId: "span_sp", runtimeEnvironmentId: cp.environment.id },
{
select: {
id: true,
runtimeEnvironmentId: true,
lockedById: true,
lockedToVersionId: true,
},
},
prisma17 as unknown as PrismaClient
);
expect(found).not.toBeNull();
const env = await resolver.resolveAuthenticatedEnv(found!.runtimeEnvironmentId);
expect(env!.id).toBe(cp.environment.id);
// AuthenticatedEnvironment carries org at the TOP LEVEL (env.organization), not under
// project — the decomposed SpanPresenter reads env.organization.{id,slug,title}.
expect(env!.organization.title).toBe(cp.organization.title);
expect(env!.project.externalRef).toBe(cp.project.externalRef);
const locked = await resolver.resolveRunLockedWorker({
lockedById: found!.lockedById,
lockedToVersionId: found!.lockedToVersionId,
});
expect(locked!.lockedBy!.filePath).toBe("src/index.ts");
expect(locked!.lockedToVersion!.version).toBe(cp.worker.version);
expect(locked!.lockedToVersion!.sdkVersion).toBe("3.0.0");
expect(await (prisma17 as unknown as PrismaClient).runtimeEnvironment.count()).toBe(0);
expect(await (prisma14 as unknown as PrismaClient).taskRun.count()).toBe(0);
}
);
});
@@ -0,0 +1,532 @@
import { describe, expect, vi } from "vitest";
// `var` (not `let`) so it is hoisted + initialized to `undefined` before the mocked
// module's getters are first read at import time (featureFlags.server reads `prisma`
// during module eval).
var dbClientHolder: any = undefined;
function setDbClient(client: any) {
dbClientHolder = client;
}
vi.mock("~/db.server", async () => {
const { Prisma } = await import("@trigger.dev/database");
return {
Prisma,
sqlDatabaseSchema: Prisma.sql(["public"]),
get prisma() {
return dbClientHolder;
},
get $replica() {
return dbClientHolder;
},
};
});
import {
heteroPostgresTest,
heteroRunOpsPostgresTest,
postgresTest,
} from "@internal/testcontainers";
import { Prisma, type PrismaClient, type WaitpointStatus } from "@trigger.dev/database";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import {
WaitpointListPresenter,
type WaitpointListOptions,
} from "~/presenters/v3/WaitpointListPresenter.server";
vi.setConfig({ testTimeout: 90_000 });
type SeedContext = {
projectId: string;
environmentId: string;
};
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
// V2 so determineEngineVersion does not short-circuit to the V1 mismatch gate.
engine: "V2",
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
// PRODUCTION so determineEngineVersion takes the deployment branch (an empty
// promotion read on the real container) and falls through to project.engine = V2.
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `sc-${slug}`,
},
});
return { projectId: project.id, environmentId: runtimeEnvironment.id };
}
type SeedWaitpoint = {
id: string;
type?: "MANUAL" | "RUN";
status?: WaitpointStatus;
outputIsError?: boolean;
idempotencyKey?: string;
inactiveIdempotencyKey?: string | null;
userProvidedIdempotencyKey?: boolean;
tags?: string[];
createdAt?: Date;
};
async function seedWaitpoint(
prisma: PrismaClient,
ctx: SeedContext,
wp: SeedWaitpoint
): Promise<void> {
await prisma.waitpoint.create({
data: {
id: wp.id,
friendlyId: `wp_${wp.id}`,
type: wp.type ?? "MANUAL",
status: wp.status ?? "PENDING",
outputIsError: wp.outputIsError ?? false,
idempotencyKey: wp.idempotencyKey ?? `idem-${wp.id}`,
userProvidedIdempotencyKey: wp.userProvidedIdempotencyKey ?? false,
inactiveIdempotencyKey: wp.inactiveIdempotencyKey ?? null,
tags: wp.tags ?? [],
createdAt: wp.createdAt ?? new Date(),
projectId: ctx.projectId,
environmentId: ctx.environmentId,
},
});
}
function baseOptions(
environmentId: string,
overrides: Partial<WaitpointListOptions> = {}
): WaitpointListOptions {
return {
environment: {
id: environmentId,
type: "PRODUCTION",
project: { id: "irrelevant", engine: "V2" },
apiKey: "tr_prod_test",
},
...overrides,
};
}
// The exact presenter scan SQL, run directly for the byte-identity / ORDER-BY proof.
function rawScan(
prisma: PrismaClient,
environmentId: string,
direction: "forward" | "backward",
limit: number
) {
const schema = Prisma.sql(["public"]);
return prisma.$queryRaw`
SELECT
w.id,
w."friendlyId",
w.status,
w."completedAt",
w."completedAfter",
w."outputIsError",
w."idempotencyKey",
w."idempotencyKeyExpiresAt",
w."inactiveIdempotencyKey",
w."userProvidedIdempotencyKey",
w."tags",
w."createdAt"
FROM
${schema}."Waitpoint" w
WHERE
w."environmentId" = ${environmentId}
AND w.type = 'MANUAL'
ORDER BY
${direction === "forward" ? Prisma.sql`w.id DESC` : Prisma.sql`w.id ASC`}
LIMIT ${limit}`;
}
describe("WaitpointListPresenter read-route", () => {
// Single-DB short-circuits to one handle (passthrough).
postgresTest("passthrough: single handle, legacy closures never touched", async ({ prisma }) => {
setDbClient(prisma);
const ctx = await seedParents(prisma, "passthrough");
await seedWaitpoint(prisma, ctx, { id: "wp00000000000000000000001", status: "PENDING" });
await seedWaitpoint(prisma, ctx, {
id: "wp00000000000000000000002",
status: "COMPLETED",
outputIsError: false,
tags: ["b", "a"],
});
await seedWaitpoint(prisma, ctx, {
id: "wp00000000000000000000003",
status: "COMPLETED",
outputIsError: true,
});
// Non-MANUAL row that must be excluded by w.type = 'MANUAL'.
await seedWaitpoint(prisma, ctx, { id: "wp00000000000000000000099", type: "RUN" });
// Spy: any would-be legacy handle access throws if invoked.
const legacyThrows = new Proxy(
{},
{
get() {
throw new Error("legacy handle must never be touched in passthrough");
},
}
) as unknown as PrismaClient;
const presenter = new WaitpointListPresenter(prisma, prisma);
const result = await presenter.call(baseOptions(ctx.environmentId, { pageSize: 2 }));
expect(result.success).toBe(true);
if (!result.success) return;
// Page of 2, id DESC (forward).
expect(result.tokens.map((t) => t.id)).toEqual([
"wp_wp00000000000000000000003",
"wp_wp00000000000000000000002",
]);
expect(result.pagination.next).toBe("wp00000000000000000000002");
expect(result.pagination.previous).toBeUndefined();
expect(result.hasAnyTokens).toBe(true);
// Matches a direct $queryRaw over the same SQL (excludes RUN type).
const direct = (await rawScan(prisma, ctx.environmentId, "forward", 3)) as { id: string }[];
expect(direct.map((r) => r.id)).toEqual([
"wp00000000000000000000003",
"wp00000000000000000000002",
"wp00000000000000000000001",
]);
// Constructing with a throwing legacy handle but no split must never invoke it.
const presenterWithLegacy = new WaitpointListPresenter(prisma, prisma, {
runOpsLegacyReplica: legacyThrows,
// splitEnabled omitted => passthrough.
});
const result2 = await presenterWithLegacy.call(baseOptions(ctx.environmentId, { pageSize: 2 }));
expect(result2.success).toBe(true);
});
// Raw paginated scan byte-identical + identical ORDER-BY across PG14/PG17.
heteroPostgresTest(
"keyset scan byte-identical + identical ORDER-BY on PG14 and PG17",
async ({ prisma14, prisma17 }) => {
const corpus: SeedWaitpoint[] = [
{
id: "wp10000000000000000000001",
status: "PENDING",
tags: ["alpha", "beta"],
createdAt: new Date("2024-01-01T00:00:00Z"),
},
{
id: "wp10000000000000000000002",
status: "COMPLETED",
outputIsError: false,
idempotencyKey: "key-2",
userProvidedIdempotencyKey: true,
inactiveIdempotencyKey: "old-2",
createdAt: new Date("2024-02-01T00:00:00Z"),
},
{
id: "wp10000000000000000000003",
status: "COMPLETED",
outputIsError: true,
tags: ["gamma"],
createdAt: new Date("2024-03-01T00:00:00Z"),
},
{
id: "wp10000000000000000000004",
status: "PENDING",
createdAt: new Date("2024-04-01T00:00:00Z"),
},
{
id: "wp10000000000000000000005",
status: "COMPLETED",
outputIsError: false,
createdAt: new Date("2024-05-01T00:00:00Z"),
},
// excluded
{ id: "wp10000000000000000000099", type: "RUN" },
];
// determineEngineVersion reads the module-level prisma (the holder); point it at
// prisma14, which holds the env used by the presenter call below.
setDbClient(prisma14);
const ctx14 = await seedParents(prisma14, "hetero14");
const ctx17 = await seedParents(prisma17, "hetero17");
for (const wp of corpus) {
await seedWaitpoint(prisma14, ctx14, wp);
await seedWaitpoint(prisma17, ctx17, wp);
}
for (const direction of ["forward", "backward"] as const) {
const rows14 = (await rawScan(prisma14, ctx14.environmentId, direction, 100)) as any[];
const rows17 = (await rawScan(prisma17, ctx17.environmentId, direction, 100)) as any[];
// Identical ORDER-BY sequence across versions.
expect(rows14.map((r) => r.id)).toEqual(rows17.map((r) => r.id));
// Byte-identical row content (id-keyed so env-id difference doesn't matter — env id is not selected).
expect(rows14).toEqual(rows17);
// The MANUAL filter excludes the RUN waitpoint.
expect(rows14.some((r) => r.id === "wp10000000000000000000099")).toBe(false);
}
// Same with a cursor active (forward => id < cursor) — exercised via the presenter.
const presenter14 = new WaitpointListPresenter(prisma14, prisma14);
const cursored = await presenter14.call(
baseOptions(ctx14.environmentId, { pageSize: 2, cursor: "wp10000000000000000000004" })
);
expect(cursored.success).toBe(true);
if (cursored.success) {
expect(cursored.tokens.map((t) => t.id)).toEqual([
"wp_wp10000000000000000000003",
"wp_wp10000000000000000000002",
]);
}
}
);
// Split scan merges migrated (new/PG17) + abandoned (legacy/PG14) tokens
// in one keyset-ordered page; legacy READ REPLICA hit only when the new DB doesn't fill the page.
// Structural: readRoute has no legacy-primary/writer field — only runOpsLegacyReplica.
heteroPostgresTest(
"split merge serves new + legacy tokens; legacy read only when new doesn't fill the page",
async ({ prisma14, prisma17 }) => {
// determineEngineVersion reads the module-level prisma (the holder) = the new DB.
setDbClient(prisma17);
// Same env id on both DBs (FK parents must exist on each side for the env-scoped WHERE).
const ctx17 = await seedParents(prisma17, "split17");
await seedParentsWithEnvId(prisma14, "split14", ctx17.environmentId, ctx17.projectId);
// New (PG17): the two most-recent (highest id) MANUAL tokens. ...004 carries the
// authoritative post-migration status (COMPLETED) and also exists on legacy as PENDING.
await seedWaitpoint(prisma17, ctx17, { id: "wp20000000000000000000005" });
await seedWaitpoint(prisma17, ctx17, {
id: "wp20000000000000000000004",
status: "COMPLETED",
outputIsError: false,
});
// Legacy (PG14): older in-retention tokens (lower ids), interleaved across the keyset order,
// plus a stale mid-migration copy of ...004 that the de-dupe must discard.
await seedWaitpoint(prisma14, ctx17, { id: "wp20000000000000000000004", status: "PENDING" });
await seedWaitpoint(prisma14, ctx17, { id: "wp20000000000000000000003" });
await seedWaitpoint(prisma14, ctx17, { id: "wp20000000000000000000002" });
await seedWaitpoint(prisma14, ctx17, { id: "wp20000000000000000000001" });
// Wrap the legacy client to count scans (now via waitpoint.findMany after the fix).
let legacyScanCount = 0;
const legacyCounted = new Proxy(prisma14, {
get(target, prop, receiver) {
if (prop === "waitpoint") {
const real = Reflect.get(target, prop, receiver);
return new Proxy(real, {
get(t, p) {
if (p === "findMany") {
legacyScanCount++;
return t.findMany.bind(t);
}
return (t as any)[p];
},
});
}
return Reflect.get(target, prop, receiver);
},
}) as PrismaClient;
const presenter = new WaitpointListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: legacyCounted,
splitEnabled: true,
});
// pageSize 4 < union of 5 => new DB (2 rows) does NOT fill page+1=5, so legacy is scanned.
const result = await presenter.call(baseOptions(ctx17.environmentId, { pageSize: 4 }));
expect(result.success).toBe(true);
if (!result.success) return;
// Keyset-ordered union, id DESC, page of 4 (one over-fetch dropped: the oldest id).
expect(result.tokens.map((t) => t.id)).toEqual([
"wp_wp20000000000000000000005",
"wp_wp20000000000000000000004",
"wp_wp20000000000000000000003",
"wp_wp20000000000000000000002",
]);
// hasMore => next cursor is the 4th id.
expect(result.pagination.next).toBe("wp20000000000000000000002");
expect(legacyScanCount).toBeGreaterThan(0);
// De-dupe: ...004 exists on both sides; it appears exactly once and the new-DB copy
// (COMPLETED, not the legacy PENDING => WAITING) is authoritative.
const dupes = result.tokens.filter((t) => t.id === "wp_wp20000000000000000000004");
expect(dupes).toHaveLength(1);
expect(dupes[0]?.status).toBe("COMPLETED");
// Now a page the new DB fully satisfies => legacy must NOT be scanned.
legacyScanCount = 0;
const presenter2 = new WaitpointListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: legacyCounted,
splitEnabled: true,
});
// pageSize 1 => page+1 = 2; new DB has 2 rows => fills the over-fetch, skip legacy.
const result2 = await presenter2.call(baseOptions(ctx17.environmentId, { pageSize: 1 }));
expect(result2.success).toBe(true);
if (result2.success) {
expect(result2.tokens.map((t) => t.id)).toEqual(["wp_wp20000000000000000000005"]);
}
expect(legacyScanCount).toBe(0);
}
);
// Empty-state probe is dual-DB during the window (no false-empty), and reads only
// _replica when split is off.
heteroPostgresTest(
"empty-state probe is dual-DB during the window",
async ({ prisma14, prisma17 }) => {
setDbClient(prisma17);
const ctx = await seedParents(prisma17, "probe17");
await seedParentsWithEnvId(prisma14, "probe14", ctx.environmentId, ctx.projectId);
// Zero MANUAL on NEW, exactly one on LEGACY.
await seedWaitpoint(prisma14, ctx, { id: "wp30000000000000000000001" });
// Filter yields an empty page (no token has this idempotencyKey) so the probe runs.
const splitPresenter = new WaitpointListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: prisma14,
splitEnabled: true,
});
const r1 = await splitPresenter.call(
baseOptions(ctx.environmentId, { idempotencyKey: "no-such-key" })
);
expect(r1.success).toBe(true);
if (r1.success) {
// Probe found the legacy row => not false-empty.
expect(r1.tokens).toEqual([]);
expect(r1.hasAnyTokens).toBe(true);
}
// Zero on both => empty (post-termination / past-retention normal response).
await prisma14.waitpoint.deleteMany({ where: { environmentId: ctx.environmentId } });
const r2 = await splitPresenter.call(
baseOptions(ctx.environmentId, { idempotencyKey: "no-such-key" })
);
expect(r2.success).toBe(true);
if (r2.success) {
expect(r2.hasAnyTokens).toBe(false);
}
// split off => probe reads only _replica, never the legacy handle (throws if touched).
const legacyThrows = new Proxy(
{},
{
get() {
throw new Error("legacy handle must never be touched when split is off");
},
}
) as unknown as PrismaClient;
const passthroughPresenter = new WaitpointListPresenter(prisma17, prisma17, {
runOpsLegacyReplica: legacyThrows,
});
const r3 = await passthroughPresenter.call(
baseOptions(ctx.environmentId, { idempotencyKey: "no-such-key" })
);
expect(r3.success).toBe(true);
if (r3.success) {
// Nothing on the new DB and split off => empty.
expect(r3.hasAnyTokens).toBe(false);
}
}
);
heteroRunOpsPostgresTest(
"scan against dedicated RunOpsPrismaClient (splitEnabled): returns waitpoints from new DB",
async ({ prisma14, prisma17 }) => {
setDbClient(prisma14);
const envId = "env_rawscan_wp_00000000001";
const projId = "proj_rawscan_wp_0000000001";
await seedParentsWithEnvId(prisma14, "rawscan-wp14", envId, projId);
await (prisma17 as RunOpsPrismaClient).waitpoint.create({
data: {
id: "rwp0000000000000000000001",
friendlyId: "wp_rwp0000000000000000000001",
type: "MANUAL",
status: "PENDING",
idempotencyKey: "idem-rawscan-wp-1",
userProvidedIdempotencyKey: false,
outputIsError: false,
tags: [],
projectId: projId,
environmentId: envId,
},
});
const presenter = new WaitpointListPresenter(prisma14 as any, prisma14 as any, {
runOpsNew: prisma17 as any,
runOpsLegacyReplica: prisma14 as any,
splitEnabled: true,
});
const result = await presenter.call({
environment: {
id: envId,
type: "PRODUCTION",
project: { id: projId, engine: "V2" },
apiKey: "tr_prod_rawscan",
},
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.tokens.map((t) => t.id)).toContain("wp_rwp0000000000000000000001");
}
);
});
// Seed org/project/env reusing a caller-supplied env+project id (so the same env id exists on a
// second DB for the cross-DB union/probe cases).
async function seedParentsWithEnvId(
prisma: PrismaClient,
slug: string,
environmentId: string,
projectId: string
): Promise<void> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
engine: "V2",
},
});
await prisma.runtimeEnvironment.create({
data: {
id: environmentId,
slug: `env-${slug}`,
type: "PRODUCTION",
projectId,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `sc-${slug}`,
},
});
}
@@ -0,0 +1,170 @@
// Real PG14 (control-plane) + PG17 (run-ops) proof for the waitpoint presenter after its
// inlined environment select was decomposed onto the ControlPlaneResolver. The waitpoint
// scalar row lives on PG17 (run-ops); the env (apiKey/organizationId) lives on PG14
// (control-plane), with the cross-seam Waitpoint FKs dropped. The presenter reads waitpoint
// scalars + environmentId from run-ops and resolves the env-derived fields from control-plane.
// The DB is never mocked; the .count() proof shows neither DB joins the other.
import { heteroPostgresTest } from "@internal/testcontainers";
import { describe, expect, vi } from "vitest";
// The presenter resolves the env off the module-level `controlPlaneResolver` singleton, which reads
// the `~/db.server` `prisma` singleton (split off -> controlPlanePrimary). We point that proxy at the
// REAL control-plane container (PG14). The DB is NEVER mocked: the proxy forwards to a real client.
const primaryHolder = vi.hoisted(() => ({ client: undefined as any }));
vi.mock("~/db.server", async () => {
const { Prisma } = await import("@trigger.dev/database");
const lazyProxy = (holder: { client: any }, label: string) =>
new Proxy(
{},
{
get(_t, prop) {
if (!holder.client) {
throw new Error(`${label} not set for this test`);
}
return holder.client[prop];
},
}
);
const proxy = lazyProxy(primaryHolder, "primaryHolder.client");
return {
prisma: proxy,
$replica: proxy,
runOpsNewPrisma: proxy,
sqlDatabaseSchema: Prisma.sql([`public`]),
};
});
import type { PrismaClient } from "@trigger.dev/database";
import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server";
import { ControlPlaneCache } from "~/v3/runOpsMigration/controlPlaneCache.server";
import { ControlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
const WAITPOINT_CROSS_SEAM_FKS = [
"Waitpoint_environmentId_fkey",
"Waitpoint_projectId_fkey",
] as const;
async function dropWaitpointCrossSeamFks(prisma: PrismaClient) {
for (const c of WAITPOINT_CROSS_SEAM_FKS) {
await prisma.$executeRawUnsafe(`ALTER TABLE "Waitpoint" DROP CONSTRAINT IF EXISTS "${c}"`);
}
}
let n = 0;
async function seedControlPlane(prisma: PrismaClient) {
const s = n++;
const organization = await prisma.organization.create({
data: { title: `Org ${s}`, slug: `org-${s}` },
});
const project = await prisma.project.create({
data: {
name: `P ${s}`,
slug: `p-${s}`,
externalRef: `proj_${s}`,
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type: "PRODUCTION",
slug: `env-${s}`,
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_${s}`,
pkApiKey: `pk_${s}`,
shortcode: `sc_${s}`,
},
});
return { organization, project, environment };
}
async function seedWaitpoint(
prisma: PrismaClient,
ctx: { environmentId: string; projectId: string }
) {
const s = n++;
return prisma.waitpoint.create({
data: {
id: `waitpoint_${s}_pg17`,
friendlyId: `waitpoint_fr_${s}`,
type: "MANUAL",
status: "PENDING",
idempotencyKey: `idem_${s}`,
userProvidedIdempotencyKey: false,
environmentId: ctx.environmentId,
projectId: ctx.projectId,
},
});
}
describe("waitpoint presenter cross-DB read-through", () => {
heteroPostgresTest(
"waitpoint scalars resolve from run-ops; apiKey/organizationId resolve from control-plane",
async ({ prisma14, prisma17 }) => {
await dropWaitpointCrossSeamFks(prisma17 as unknown as PrismaClient);
const cp = await seedControlPlane(prisma14 as unknown as PrismaClient);
const waitpoint = await seedWaitpoint(prisma17 as unknown as PrismaClient, {
environmentId: cp.environment.id,
projectId: cp.project.id,
});
// Run-ops read: waitpoint scalars + environmentId, no environment relation.
const found = await (prisma17 as unknown as PrismaClient).waitpoint.findFirst({
where: { friendlyId: waitpoint.friendlyId, environmentId: cp.environment.id },
select: { id: true, friendlyId: true, environmentId: true },
});
expect(found).not.toBeNull();
expect(found!.environmentId).toBe(cp.environment.id);
// Control-plane resolution of the env-derived fields the presenter uses.
const resolver = new ControlPlaneResolver({
controlPlanePrimary: prisma14 as unknown as PrismaClient,
controlPlaneReplica: prisma14 as unknown as PrismaClient,
cache: new ControlPlaneCache(),
splitEnabled: () => false,
});
const env = await resolver.resolveAuthenticatedEnv(found!.environmentId);
expect(env).not.toBeNull();
expect(env!.apiKey).toBe(cp.environment.apiKey);
expect(env!.organizationId).toBe(cp.organization.id);
expect(await (prisma17 as unknown as PrismaClient).runtimeEnvironment.count()).toBe(0);
expect(await (prisma14 as unknown as PrismaClient).waitpoint.count()).toBe(0);
}
);
heteroPostgresTest(
"presenter returns null when env unresolvable (no false hydrate)",
async ({ prisma14, prisma17 }) => {
await dropWaitpointCrossSeamFks(prisma17 as unknown as PrismaClient);
const cp = await seedControlPlane(prisma14 as unknown as PrismaClient);
// The waitpoint references an environmentId with NO row on control-plane (PG14).
const absentEnvironmentId = `env_absent_${n++}`;
const waitpoint = await seedWaitpoint(prisma17 as unknown as PrismaClient, {
environmentId: absentEnvironmentId,
projectId: cp.project.id,
});
// The presenter reads waitpoint scalars off its own replica (PG17); the resolver singleton
// reads the env off the `~/db.server` proxy -> control-plane (PG14), where it is absent.
primaryHolder.client = prisma14;
const presenter = new WaitpointPresenter(
prisma17 as unknown as PrismaClient,
prisma17 as unknown as PrismaClient
);
const result = await presenter.call({
friendlyId: waitpoint.friendlyId,
environmentId: absentEnvironmentId,
projectId: cp.project.id,
});
// env resolves null after the waitpoint is found -> presenter returns null, no CH hydrate.
expect(result).toBeNull();
}
);
});
@@ -0,0 +1,401 @@
import { describe, expect, vi } from "vitest";
// The presenter graph imports `~/services/clickhouse/clickhouseFactoryInstance.server` and, via
// NextRunListPresenter -> RunsRepository / runStore, reaches `~/db.server`'s `prisma`/`$replica`
// singletons (through findDisplayableEnvironment + getTaskIdentifiers). We stub those two wiring
// boundaries so the module loads + the connected-runs hydrate can be pointed at the per-test REAL
// containers. The DB is NEVER mocked: every assertion runs against real Postgres/ClickHouse.
//
// * `~/db.server` — `prisma`/`$replica`/`runOpsNewPrisma` singletons. The read-through cases pass
// explicit client handles to the presenter ctor so these proxies are never read on those paths.
// For the connected-runs hydrate the proxies forward lazily to the test's real legacy handle.
// The run-ops new/legacy clients forward to the new/legacy per-test holders respectively.
// * clickhouseFactory singleton — overridden to hand back the per-test ClickHouse so the
// connected-runs hydrate uses a real CH container (a wiring override, not a DB mock).
const legacyReplicaHolder = vi.hoisted(() => ({ client: undefined as any }));
const newClientHolder = vi.hoisted(() => ({ client: undefined as any }));
const clickhouseHolder = vi.hoisted(() => ({ client: undefined as any }));
vi.mock("~/db.server", async () => {
const { Prisma } = await import("@trigger.dev/database");
const lazyProxy = (holder: { client: any }, label: string) =>
new Proxy(
{},
{
get(_t, prop) {
if (!holder.client) {
throw new Error(`${label} not set for this test`);
}
return holder.client[prop];
},
}
);
const replicaProxy = lazyProxy(legacyReplicaHolder, "legacyReplicaHolder.client");
return {
prisma: replicaProxy,
$replica: replicaProxy,
runOpsNewPrisma: lazyProxy(newClientHolder, "newClientHolder.client"),
runOpsNewReplica: lazyProxy(newClientHolder, "newClientHolder.client"),
runOpsLegacyPrisma: replicaProxy,
runOpsLegacyReplica: replicaProxy,
sqlDatabaseSchema: Prisma.sql([`public`]),
};
});
vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
clickhouseFactory: {
getClickhouseForOrganization: async () => {
if (!clickhouseHolder.client) {
throw new Error("clickhouseHolder.client not set for this test");
}
return clickhouseHolder.client;
},
},
}));
import {
createPostgresContainer,
heteroPostgresTest,
replicationContainerTest,
} from "@internal/testcontainers";
import type { PrismaClient, WaitpointType } from "@trigger.dev/database";
import { PrismaClient as PrismaClientCtor } from "@trigger.dev/database";
import { setTimeout } from "node:timers/promises";
import type { PrismaReplicaClient } from "~/db.server";
import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server";
import { setupClickhouseReplication } from "./utils/replicationUtils";
vi.setConfig({ testTimeout: 90_000 });
// A read client whose waitpoint.findFirst is recorded; throws if used after being marked
// forbidden, so we can prove a store was NEVER read. Every other access forwards to the real
// client (so the inlined `environment` join + connectedRuns relation still resolve).
function recording(client: PrismaClient, opts: { forbidden?: boolean } = {}) {
const calls: unknown[] = [];
return {
handle: new Proxy(client, {
get(target, prop) {
if (prop === "waitpoint") {
return new Proxy((target as any).waitpoint, {
get(wpTarget, wpProp) {
if (wpProp === "findFirst") {
return (args: unknown) => {
calls.push(args);
if (opts.forbidden) {
throw new Error("this store must never be read");
}
return (wpTarget as any).findFirst(args);
};
}
return (wpTarget as any)[wpProp];
},
});
}
return (target as any)[prop];
},
}) as unknown as PrismaReplicaClient,
calls,
};
}
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
};
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${slug}`,
pkApiKey: `pk_dev_${slug}`,
shortcode: `sc-${slug}`,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: runtimeEnvironment.id,
};
}
/** Mirrors the org/project/env parents onto a second DB with the SAME ids (FKs need them). */
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
await prisma.organization.create({
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: ctx.projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: ctx.organizationId,
externalRef: `proj-${slug}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environmentId,
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: ctx.projectId,
organizationId: ctx.organizationId,
apiKey: `tr_dev_${slug}_b`,
pkApiKey: `pk_dev_${slug}_b`,
shortcode: `sc-${slug}-b`,
},
});
}
async function seedWaitpoint(
prisma: PrismaClient,
ctx: SeedContext,
friendlyId: string,
overrides: Partial<{
type: WaitpointType;
tags: string[];
output: string;
connectedRunFriendlyIds: string[];
}> = {}
) {
return prisma.waitpoint.create({
data: {
friendlyId,
type: overrides.type ?? "MANUAL",
status: "COMPLETED",
idempotencyKey: `idem-${friendlyId}`,
userProvidedIdempotencyKey: false,
output: overrides.output ?? JSON.stringify({ hello: "world" }),
outputType: "application/json",
outputIsError: false,
completedAt: new Date(),
tags: overrides.tags ?? ["a", "b"],
projectId: ctx.projectId,
environmentId: ctx.environmentId,
...(overrides.connectedRunFriendlyIds
? {
connectedRuns: {
connect: overrides.connectedRunFriendlyIds.map((friendlyId) => ({ friendlyId })),
},
}
: {}),
},
});
}
async function createRun(
prisma: PrismaClient,
ctx: SeedContext,
run: { friendlyId: string; taskIdentifier?: string }
) {
return prisma.taskRun.create({
data: {
friendlyId: run.friendlyId,
taskIdentifier: run.taskIdentifier ?? "my-task",
status: "PENDING",
payload: JSON.stringify({ foo: run.friendlyId }),
traceId: run.friendlyId,
spanId: run.friendlyId,
queue: "test",
runtimeEnvironmentId: ctx.environmentId,
projectId: ctx.projectId,
organizationId: ctx.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
}
const callArgs = (ctx: SeedContext, friendlyId: string) => ({
friendlyId,
environmentId: ctx.environmentId,
projectId: ctx.projectId,
});
describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connected runs)", () => {
// new-DB short-circuit. Waitpoint on NEW (PG17), legacy
// wrapped so its waitpoint.findFirst throws if invoked. The lookup answers from NEW and must
// NEVER fall through to legacy.
heteroPostgresTest(
"waitpoint on the new DB resolves without touching the legacy replica",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma17, "newonly");
const seeded = await seedWaitpoint(prisma17, ctx, "waitpoint_newonly");
// The env lives on the new DB here; the resolver singleton reads it through the
// `~/db.server` proxy.
legacyReplicaHolder.client = prisma17;
const newClient = recording(prisma17);
const legacy = recording(prisma14, { forbidden: true });
const presenter = new WaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: newClient.handle,
legacyReplica: legacy.handle,
});
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
expect(result?.id).toBe(seeded.friendlyId);
// New-first short-circuit: legacy never probed (the throwing handle proves it).
expect(newClient.calls.length).toBe(1);
expect(legacy.calls.length).toBe(0);
}
);
// single-DB passthrough. No read-through deps -> exactly one plain
// findFirst against the single `_replica` handle; the split branch is structurally never entered
// (no second handle is injected). The connected-runs hydrate forwards `undefined` deps.
heteroPostgresTest(
"no read-through deps -> one plain findFirst on the single replica (passthrough)",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "passthrough");
const seeded = await seedWaitpoint(prisma14, ctx, "waitpoint_passthrough", {
tags: ["one"],
});
const single = recording(prisma14);
const second = recording(prisma17, { forbidden: true });
legacyReplicaHolder.client = single.handle;
newClientHolder.client = second.handle;
// No readThroughDeps -> ctor defaults _replica to the (mocked) `$replica` singleton, which
// forwards to `single.handle`. The split branch needs an injected second handle to fire, so
// it cannot: passthrough is structural.
const presenter = new WaitpointPresenter();
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
expect(result?.id).toBe(seeded.friendlyId);
expect(result?.tags).toEqual(["one"]);
// Exactly one read on the single client; the second handle is never touched.
expect(single.calls.length).toBe(1);
expect(second.calls.length).toBe(0);
}
);
});
describe("WaitpointPresenter connected-runs hydrate routed through read-through (PG14 + PG17 + CH)", () => {
// Waitpoint detail + connected runs resolve on run-ops NEW. The
// waitpoint + its 2 connected runs live on the new (PG17) DB; CH gets the run id-set so the
// threaded NextRunListPresenter hydrate returns them. Proves the read-through deps are forwarded
// so the connected-runs hydrate flows through the routed store.
replicationContainerTest(
"waitpoint + 2 connected runs resolve on the new DB via the routed hydrate",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
// `prisma`/`postgresContainer` is the PG14 legacy + CH replication source. The new DB (PG17)
// is created alongside; we seed the waitpoint + runs on it so CH replicates from PG14 — so we
// mirror the runs onto PG14 (replication source) and the waitpoint+runs onto PG17 (authoritative).
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClientCtor({ datasources: { db: { url: newUrl } } });
legacyReplicaHolder.client = prisma;
newClientHolder.client = prismaNew;
clickhouseHolder.client = clickhouse;
try {
const ctx = await seedParents(prisma, "connectednew");
await mirrorParents(prismaNew, ctx, "connectednew");
// The connected runs land on PG14 (CH replication source) AND on PG17 (authoritative,
// same ids/friendlyIds) so the routed hydrate resolves them from NEW.
const runA = await createRun(prisma, ctx, { friendlyId: "run_connA" });
const runB = await createRun(prisma, ctx, { friendlyId: "run_connB" });
const newRunA = await createRun(prismaNew, ctx, {
friendlyId: "run_connA",
taskIdentifier: "my-task-NEW",
});
const newRunB = await createRun(prismaNew, ctx, {
friendlyId: "run_connB",
taskIdentifier: "my-task-NEW",
});
await prismaNew.taskRun.update({
where: { friendlyId: "run_connA" },
data: { id: runA.id },
});
await prismaNew.taskRun.update({
where: { friendlyId: "run_connB" },
data: { id: runB.id },
});
// Waitpoint authoritative on NEW, connected to the 2 runs.
const seeded = await seedWaitpoint(prismaNew, ctx, "waitpoint_connectednew", {
connectedRunFriendlyIds: ["run_connA", "run_connB"],
});
// Wait for CH replication so the connected-run id-set page is non-empty.
await setTimeout(1500);
const presenter = new WaitpointPresenter(prisma, prisma, {
splitEnabled: true,
newClient: prismaNew,
legacyReplica: prisma,
});
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
expect(result?.id).toBe(seeded.friendlyId);
expect(result?.connectedRuns.map((r) => r.friendlyId).sort()).toEqual([
"run_connA",
"run_connB",
]);
// The connected runs carry the PG17-only taskIdentifier -> they hydrated from the threaded
// newClient (PG17), proving the routed store is armed.
expect(result?.connectedRuns.every((r) => r.taskIdentifier === "my-task-NEW")).toBe(true);
void newRunA;
void newRunB;
} finally {
await prismaNew.$disconnect();
}
}
);
});
describe("WaitpointPresenter bare-ctor production default activates readThroughRun", () => {
heteroPostgresTest(
"ksuid waitpoint on the new DB resolves via readThroughRun production defaults",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma17, "proddefault");
const seeded = await seedWaitpoint(prisma17, ctx, "waitpoint_proddefault", {
tags: ["p", "q"],
});
// runOpsNewReplica default -> PG17; env resolver reads via legacyReplicaHolder.
newClientHolder.client = prisma17;
legacyReplicaHolder.client = prisma17;
// No newClient/legacyReplica injected — production ctor shape.
const presenter = new WaitpointPresenter(undefined, undefined, { splitEnabled: true });
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
expect(result?.id).toBe(seeded.friendlyId);
expect(result?.tags).toEqual(["p", "q"]);
}
);
});
@@ -0,0 +1,240 @@
import { describe, expect, vi } from "vitest";
var dbClientHolder: any = undefined;
function setDbClient(client: any) {
dbClientHolder = client;
}
vi.mock("~/db.server", () => ({
get prisma() {
return dbClientHolder;
},
get $replica() {
return dbClientHolder;
},
}));
import { heteroRunOpsPostgresTest, postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import {
WaitpointTagListPresenter,
type TagListOptions,
} from "~/presenters/v3/WaitpointTagListPresenter.server";
vi.setConfig({ testTimeout: 120_000 });
type LegacySeedContext = {
projectId: string;
environmentId: string;
};
async function seedLegacyParents(prisma: PrismaClient, slug: string): Promise<LegacySeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
engine: "V2",
},
});
const env = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `sc-${slug}`,
},
});
return { projectId: project.id, environmentId: env.id };
}
async function seedLegacyParentsWithIds(
prisma: PrismaClient,
slug: string,
environmentId: string,
projectId: string
): Promise<void> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
engine: "V2",
},
});
await prisma.runtimeEnvironment.create({
data: {
id: environmentId,
slug: `env-${slug}`,
type: "PRODUCTION",
projectId,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `sc-${slug}`,
},
});
}
function opts(environmentId: string, overrides: Partial<TagListOptions> = {}): TagListOptions {
return { environmentId, ...overrides };
}
describe("WaitpointTagListPresenter read-route", () => {
postgresTest(
"passthrough: no readRoute => _replica only, legacy handle never touched",
async ({ prisma }) => {
setDbClient(prisma);
const ctx = await seedLegacyParents(prisma, "pass");
await prisma.waitpointTag.createMany({
data: [
{
id: "ct000000000000000000001",
name: "alpha",
environmentId: ctx.environmentId,
projectId: ctx.projectId,
},
{
id: "ct000000000000000000002",
name: "beta",
environmentId: ctx.environmentId,
projectId: ctx.projectId,
},
{
id: "ct000000000000000000003",
name: "gamma",
environmentId: ctx.environmentId,
projectId: ctx.projectId,
},
],
});
const legacyThrows = new Proxy(
{},
{
get() {
throw new Error("legacy handle must not be touched in passthrough");
},
}
) as unknown as PrismaClient;
const presenter = new WaitpointTagListPresenter(prisma, prisma, {
runOpsLegacyReplica: legacyThrows,
});
const result = await presenter.call(opts(ctx.environmentId, { pageSize: 10 }));
expect(result.tags.map((t) => t.name)).toEqual(["gamma", "beta", "alpha"]);
expect(result.hasMore).toBe(false);
expect(result.currentPage).toBe(1);
}
);
heteroRunOpsPostgresTest(
"split merge: tags from NEW and LEGACY are deduped and ordered id desc",
async ({ prisma14, prisma17 }) => {
setDbClient(prisma14);
const envId = "env0000000000000000001";
const projId = "proj000000000000000001";
await seedLegacyParentsWithIds(prisma14, "merge14", envId, projId);
await (prisma17 as RunOpsPrismaClient).waitpointTag.createMany({
data: [
{ id: "mt000000000000000000005", name: "echo", environmentId: envId, projectId: projId },
{ id: "mt000000000000000000004", name: "delta", environmentId: envId, projectId: projId },
],
});
await (prisma14 as PrismaClient).waitpointTag.createMany({
data: [
{
id: "mt000000000000000000004",
name: "delta-stale",
environmentId: envId,
projectId: projId,
},
{
id: "mt000000000000000000003",
name: "charlie",
environmentId: envId,
projectId: projId,
},
{ id: "mt000000000000000000002", name: "bravo", environmentId: envId, projectId: projId },
{ id: "mt000000000000000000001", name: "alpha", environmentId: envId, projectId: projId },
],
});
const presenter = new WaitpointTagListPresenter(prisma14 as any, prisma14 as any, {
runOpsNew: prisma17 as any,
runOpsLegacyReplica: prisma14 as any,
splitEnabled: true,
});
const result = await presenter.call(opts(envId, { pageSize: 4 }));
expect(result.tags.map((t) => t.name)).toEqual(["echo", "delta", "charlie", "bravo"]);
expect(result.hasMore).toBe(true);
const dupes = result.tags.filter((t) => t.name === "delta-stale");
expect(dupes).toHaveLength(0);
expect(result.tags.filter((t) => t.name === "delta")).toHaveLength(1);
}
);
heteroRunOpsPostgresTest(
"offset window: page 2 returns the correct slice of the merged prefix",
async ({ prisma14, prisma17 }) => {
setDbClient(prisma14);
const envId = "env0000000000000000002";
const projId = "proj000000000000000002";
await seedLegacyParentsWithIds(prisma14, "page214", envId, projId);
await (prisma17 as RunOpsPrismaClient).waitpointTag.createMany({
data: [
{ id: "pt000000000000000000006", name: "f", environmentId: envId, projectId: projId },
{ id: "pt000000000000000000005", name: "e", environmentId: envId, projectId: projId },
],
});
await (prisma14 as PrismaClient).waitpointTag.createMany({
data: [
{ id: "pt000000000000000000004", name: "d", environmentId: envId, projectId: projId },
{ id: "pt000000000000000000003", name: "c", environmentId: envId, projectId: projId },
{ id: "pt000000000000000000002", name: "b", environmentId: envId, projectId: projId },
{ id: "pt000000000000000000001", name: "a", environmentId: envId, projectId: projId },
],
});
const presenter = new WaitpointTagListPresenter(prisma14 as any, prisma14 as any, {
runOpsNew: prisma17 as any,
runOpsLegacyReplica: prisma14 as any,
splitEnabled: true,
});
const page2 = await presenter.call(opts(envId, { pageSize: 2, page: 2 }));
expect(page2.tags.map((t) => t.name)).toEqual(["d", "c"]);
expect(page2.hasMore).toBe(true);
expect(page2.currentPage).toBe(2);
const page3 = await presenter.call(opts(envId, { pageSize: 2, page: 3 }));
expect(page3.tags.map((t) => t.name)).toEqual(["b", "a"]);
expect(page3.hasMore).toBe(false);
}
);
});