Files
triggerdotdev--trigger.dev/internal-packages/run-store/src/PostgresRunStore.dualSchema.test.ts
T
2026-07-13 13:32:57 +08:00

379 lines
13 KiB
TypeScript

import { heteroPostgresTest, heteroRunOpsPostgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import { describe, expect } from "vitest";
import { PostgresRunStore } from "./PostgresRunStore.js";
import type { CreateRunInput, RunAssociatedWaitpointInput } from "./types.js";
// The store's structural client accepts either backing Prisma client; the two generated
// clients are nominally distinct so we widen at the boundary, exactly as buildRunStore does.
type AnyClient = PrismaClient | RunOpsPrismaClient;
// The dedicated subset schema has no Organization/Project/RuntimeEnvironment models and the
// run-ops TaskRun/Waitpoint carry FK-free scalar ids, so on that variant we mint synthetic
// ids; on legacy we seed the real owning rows the FKs require.
async function seedEnvironment(
prisma: AnyClient,
schemaVariant: "legacy" | "dedicated",
slugSuffix: string
) {
if (schemaVariant === "dedicated") {
return {
organization: { id: `org_${slugSuffix}` },
project: { id: `proj_${slugSuffix}` },
environment: { id: `env_${slugSuffix}` },
};
}
const organization = await (prisma as PrismaClient).organization.create({
data: { title: `Org ${slugSuffix}`, slug: `org-${slugSuffix}` },
});
const project = await (prisma as PrismaClient).project.create({
data: {
name: `Project ${slugSuffix}`,
slug: `project-${slugSuffix}`,
externalRef: `proj_${slugSuffix}`,
organizationId: organization.id,
},
});
const environment = await (prisma as PrismaClient).runtimeEnvironment.create({
data: {
type: "DEVELOPMENT",
slug: "dev",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${slugSuffix}`,
pkApiKey: `pk_dev_${slugSuffix}`,
shortcode: `short_${slugSuffix}`,
},
});
return { organization, project, environment };
}
function buildAssociatedWaitpoint(params: {
id: string;
friendlyId: string;
projectId: string;
environmentId: string;
}): RunAssociatedWaitpointInput {
return {
id: params.id,
friendlyId: params.friendlyId,
type: "RUN",
status: "PENDING",
idempotencyKey: `idem_${params.id}`,
userProvidedIdempotencyKey: false,
projectId: params.projectId,
environmentId: params.environmentId,
};
}
function buildCreateRunInput(params: {
runId: string;
friendlyId: string;
taskIdentifier: string;
organizationId: string;
projectId: string;
runtimeEnvironmentId: string;
associatedWaitpoint?: RunAssociatedWaitpointInput;
}): CreateRunInput {
return {
data: {
id: params.runId,
engine: "V2",
status: "PENDING",
friendlyId: params.friendlyId,
runtimeEnvironmentId: params.runtimeEnvironmentId,
environmentType: "DEVELOPMENT",
organizationId: params.organizationId,
projectId: params.projectId,
taskIdentifier: params.taskIdentifier,
payload: '{"hello":"world"}',
payloadType: "application/json",
context: { foo: "bar" },
traceContext: { trace: "ctx" },
traceId: "trace_1",
spanId: "span_1",
runTags: ["alpha", "beta"],
queue: "task/my-task",
isTest: false,
taskEventStore: "taskEvent",
depth: 0,
createdAt: new Date("2024-01-01T00:00:00.000Z"),
},
snapshot: {
engine: "V2",
executionStatus: "RUN_CREATED",
description: "Run was created",
runStatus: "PENDING",
environmentId: params.runtimeEnvironmentId,
environmentType: "DEVELOPMENT",
projectId: params.projectId,
organizationId: params.organizationId,
},
associatedWaitpoint: params.associatedWaitpoint,
};
}
async function seedPendingWaitpoint(
prisma: AnyClient,
params: { id: string; friendlyId: string; projectId: string; environmentId: string }
) {
return (prisma as PrismaClient).waitpoint.create({
data: {
id: params.id,
friendlyId: params.friendlyId,
type: "MANUAL",
status: "PENDING",
idempotencyKey: `idem_${params.id}`,
userProvidedIdempotencyKey: false,
projectId: params.projectId,
environmentId: params.environmentId,
},
});
}
// Strip the prisma-managed / connection-volatile columns so two waitpoint rows born on
// different physical DBs (and via different code paths) compare field-for-field.
function normalizeWaitpoint(row: Record<string, unknown> | null) {
if (!row) return row;
const r = { ...row };
delete r.createdAt;
delete r.updatedAt;
return r;
}
// Runs the same createRun + snapshot scenario against any (client, schemaVariant) pair and
// returns the observable shapes the interface contract promises, so the legacy and dedicated
// runs can be asserted equivalent.
async function runScenario(
prisma: AnyClient,
schemaVariant: "legacy" | "dedicated",
suffix: string
) {
const store = new PostgresRunStore({
prisma: prisma as never,
readOnlyPrisma: prisma as never,
schemaVariant,
});
const env = await seedEnvironment(prisma, schemaVariant, suffix);
const runId = `run_dual_${suffix}`;
const created = await store.createRun(
buildCreateRunInput({
runId,
friendlyId: `run_friendly_${suffix}`,
taskIdentifier: "my-task",
organizationId: env.organization.id,
projectId: env.project.id,
runtimeEnvironmentId: env.environment.id,
associatedWaitpoint: buildAssociatedWaitpoint({
id: `wp_assoc_${suffix}`,
friendlyId: `waitpoint_assoc_${suffix}`,
projectId: env.project.id,
environmentId: env.environment.id,
}),
})
);
// The run read that pulls the associatedWaitpoint back (rewriteDebouncedRun shape).
const rewritten = await store.rewriteDebouncedRun(runId, {
payload: '{"hello":"again"}',
payloadType: "application/json",
});
// Two pending waitpoints to complete via a snapshot.
const w1 = `wp_${suffix}_1`;
const w2 = `wp_${suffix}_2`;
await seedPendingWaitpoint(prisma, {
id: w1,
friendlyId: `waitpoint_${suffix}_1`,
projectId: env.project.id,
environmentId: env.environment.id,
});
await seedPendingWaitpoint(prisma, {
id: w2,
friendlyId: `waitpoint_${suffix}_2`,
projectId: env.project.id,
environmentId: env.environment.id,
});
const ids = {
environmentId: env.environment.id,
environmentType: "DEVELOPMENT" as const,
projectId: env.project.id,
organizationId: env.organization.id,
};
const snapshot = await store.createExecutionSnapshot({
run: { id: runId, status: "EXECUTING", attemptNumber: 1 },
snapshot: { executionStatus: "EXECUTING_WITH_WAITPOINTS", description: "with waitpoints" },
completedWaitpoints: [
{ id: w2, index: 1 },
{ id: w1, index: 0 },
],
...ids,
});
const latest = await store.findLatestExecutionSnapshot(runId);
const joinIds = await store.findSnapshotCompletedWaitpointIds(snapshot.id);
return {
runId,
created,
rewritten,
snapshot,
latest,
joinIds,
waitpointId: `wp_assoc_${suffix}`,
w1,
w2,
};
}
function assertScenario(r: Awaited<ReturnType<typeof runScenario>>) {
// createRun returns the run with its associatedWaitpoint hydrated.
expect(r.created.id).toBe(r.runId);
expect(r.created.associatedWaitpoint).not.toBeNull();
expect(r.created.associatedWaitpoint!.id).toBe(r.waitpointId);
expect(r.created.associatedWaitpoint!.type).toBe("RUN");
expect(r.created.associatedWaitpoint!.completedByTaskRunId).toBe(r.runId);
// The run read hydrates the same associatedWaitpoint.
expect(r.rewritten.associatedWaitpoint).not.toBeNull();
expect(r.rewritten.associatedWaitpoint!.id).toBe(r.waitpointId);
// The snapshot create derives completedWaitpointOrder by index (w1 index 0, w2 index 1).
expect(r.snapshot.completedWaitpointOrder).toEqual([r.w1, r.w2]);
// The join read returns both completed waitpoints (set-equal).
expect([...r.joinIds].sort()).toEqual([r.w1, r.w2].sort());
// findLatest hydrates completedWaitpoints (set-equal) and the (null) checkpoint.
expect(r.latest).not.toBeNull();
expect(r.latest!.id).toBe(r.snapshot.id);
expect(r.latest!.checkpoint).toBeNull();
expect(r.latest!.completedWaitpoints.map((w) => w.id).sort()).toEqual([r.w1, r.w2].sort());
}
describe("PostgresRunStore dual-schema (P2-store-bodies)", () => {
// Legacy variant over the full @trigger.dev/database schema — existing behavior must hold.
heteroPostgresTest(
"createRun + snapshot relation ops work on the LEGACY client (schemaVariant=legacy)",
async ({ prisma14 }) => {
const r = await runScenario(prisma14, "legacy", "leg");
assertScenario(r);
}
);
// Dedicated variant over the @internal/run-ops-database SUBSET schema — RED before this
// task (Prisma validation error on associatedWaitpoint/completedWaitpoints), GREEN after.
heteroRunOpsPostgresTest(
"createRun + snapshot relation ops work on the DEDICATED RunOpsPrismaClient (schemaVariant=dedicated)",
async ({ prisma17 }) => {
const r = await runScenario(prisma17, "dedicated", "ded");
assertScenario(r);
}
);
// Cross-variant equivalence: the observable return contract is the same regardless of which
// backing schema produced it.
heteroRunOpsPostgresTest(
"legacy and dedicated produce equivalent return shapes",
async ({ prisma14, prisma17 }) => {
const legacy = await runScenario(prisma14, "legacy", "xleg");
const dedicated = await runScenario(prisma17, "dedicated", "xded");
// Associated waitpoint: normalize per-DB volatile columns, the rest must match.
const legW = normalizeWaitpoint(
legacy.created.associatedWaitpoint as unknown as Record<string, unknown>
);
const dedW = normalizeWaitpoint(
dedicated.created.associatedWaitpoint as unknown as Record<string, unknown>
);
// Both carry the same friendlyId/type/status/completedByTaskRunId-shaped contract;
// ids differ by suffix so compare the structural keys that must agree.
expect(legW!.type).toEqual(dedW!.type);
expect(legW!.status).toEqual(dedW!.status);
expect((legW as Record<string, unknown>).outputType).toEqual(
(dedW as Record<string, unknown>).outputType
);
// completedWaitpointOrder derivation is variant-independent.
expect(legacy.snapshot.completedWaitpointOrder.length).toEqual(
dedicated.snapshot.completedWaitpointOrder.length
);
expect(legacy.latest!.completedWaitpoints.length).toEqual(
dedicated.latest!.completedWaitpoints.length
);
}
);
// expireRunsBatch dedicated-fixture: RED before fix (Prisma.join mis-binds on dedicated client
// → 42601-class error), GREEN after (= ANY(ids::text[]) path).
heteroRunOpsPostgresTest(
"expireRunsBatch sets EXPIRED on the DEDICATED RunOpsPrismaClient (schemaVariant=dedicated)",
async ({ prisma17 }) => {
const store = new PostgresRunStore({
prisma: prisma17 as never,
readOnlyPrisma: prisma17 as never,
schemaVariant: "dedicated",
});
// Dedicated subset has no Organization/Project/RuntimeEnvironment tables — use synthetic ids.
const orgId = "org_expbatch_ded";
const projId = "proj_expbatch_ded";
const envId = "env_expbatch_ded";
const runId1 = "run_expbatch_ded_1";
const runId2 = "run_expbatch_ded_2";
for (const id of [runId1, runId2]) {
await prisma17.taskRun.create({
data: {
id,
engine: "V2",
status: "PENDING",
friendlyId: `friendly_${id}`,
runtimeEnvironmentId: envId,
environmentType: "DEVELOPMENT",
organizationId: orgId,
projectId: projId,
taskIdentifier: "my-task",
payload: "{}",
payloadType: "application/json",
traceContext: {},
traceId: `trace_${id}`,
spanId: `span_${id}`,
queue: "task/my-task",
isTest: false,
taskEventStore: "taskEvent",
depth: 0,
},
});
}
const now = new Date("2026-06-01T12:00:00.000Z");
const error = {
type: "STRING_ERROR" as const,
raw: "Run expired because the TTL was reached",
};
const count = await store.expireRunsBatch([runId1, runId2], { error, now });
expect(count).toBe(2);
for (const id of [runId1, runId2]) {
const row = await prisma17.taskRun.findUniqueOrThrow({
where: { id },
select: { status: true, completedAt: true, expiredAt: true, updatedAt: true },
});
expect(row.status).toBe("EXPIRED");
expect(row.completedAt).toEqual(now);
expect(row.expiredAt).toEqual(now);
expect(row.updatedAt).toEqual(now);
}
}
);
});