chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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");
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user