Files
triggerdotdev--trigger.dev/apps/webapp/test/waitpointListPresenter.readroute.test.ts
T
2026-07-13 13:32:57 +08:00

533 lines
18 KiB
TypeScript

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}`,
},
});
}