Skip to content

Commit d4fcc0b

Browse files
d-csclaude
andcommitted
fix(run-ops split): normalize run-ops-generation Prisma errors to the control-plane class at the store write boundary
The store can be backed by either the control-plane @trigger.dev/database client or the @internal/run-ops-database client. These are separately generated clients, each with its own copy of the Prisma runtime, so each has its own PrismaClientKnownRequestError class object (identical code, distinct module identity). A P2002 raised by the run-ops client is therefore not `instanceof` the control-plane class, so the webapp's uniform `error instanceof Prisma.PrismaClientKnownRequestError` P2002->422 conversion is skipped and a raw 500 escapes (e.g. idempotent batch re-create returned 500 not 422). PostgresRunStore now wraps its writer/replica clients so any foreign known-request-error is re-thrown as the control-plane Prisma.PrismaClientKnownRequestError (preserving code/message/meta/clientVersion), including inside runInTransaction. This immunizes the whole class of routed-write error catches regardless of which generated client backs the store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7349976 commit d4fcc0b

2 files changed

Lines changed: 252 additions & 4 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Cross-generation Prisma error normalization LOCK.
2+
//
3+
// The store can be backed by the run-ops `@internal/run-ops-database` client, a SEPARATELY
4+
// generated Prisma client with its OWN `PrismaClientKnownRequestError` class object (distinct
5+
// module identity from `@trigger.dev/database`'s, even at the same version). A P2002 raised by
6+
// the run-ops client is therefore NOT `instanceof` the control-plane
7+
// `Prisma.PrismaClientKnownRequestError` — so the webapp's uniform P2002→422 conversion
8+
// (`error instanceof Prisma.PrismaClientKnownRequestError`) is skipped and a raw 500 escapes.
9+
//
10+
// PostgresRunStore normalizes at its write boundary: a routed NEW-client P2002 surfaces such
11+
// that a control-plane `instanceof` catch (the 422 path) sees it. This test drives a REAL
12+
// duplicate-key on the REAL run-ops-generation client (prisma17) through the store and asserts
13+
// the surfaced error is recognized by the control-plane class — the exact predicate every
14+
// routed-write caller uses. Fails before the normalization (raw foreign error ⇒ instanceof false).
15+
16+
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
17+
import { Prisma } from "@trigger.dev/database";
18+
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
19+
import { describe, expect } from "vitest";
20+
import { PostgresRunStore } from "./PostgresRunStore.js";
21+
import type { CreateBatchTaskRunData } from "./types.js";
22+
23+
function makeDedicatedStore(prisma17: RunOpsPrismaClient) {
24+
return new PostgresRunStore({
25+
prisma: prisma17 as never,
26+
readOnlyPrisma: prisma17 as never,
27+
schemaVariant: "dedicated",
28+
});
29+
}
30+
31+
function batchData(overrides: Partial<CreateBatchTaskRunData> = {}): CreateBatchTaskRunData {
32+
return {
33+
id: `batch_${"x".repeat(24)}`,
34+
friendlyId: "batch_dup_friendly",
35+
runtimeEnvironmentId: "env_cgerr",
36+
status: "PENDING",
37+
runCount: 1,
38+
expectedCount: 1,
39+
batchVersion: "runengine:v2",
40+
sealed: false,
41+
...overrides,
42+
};
43+
}
44+
45+
describe("PostgresRunStore — cross-generation Prisma error normalization", () => {
46+
heteroRunOpsPostgresTest(
47+
"a routed NEW-client P2002 surfaces as a control-plane instanceof Prisma.PrismaClientKnownRequestError",
48+
async ({ prisma17 }) => {
49+
const store = makeDedicatedStore(prisma17);
50+
51+
// First create succeeds; second collides on the unique friendlyId → NEW-generation P2002.
52+
await store.createBatchTaskRun(batchData({ id: `batch_${"a".repeat(24)}` }));
53+
54+
let caught: unknown;
55+
try {
56+
await store.createBatchTaskRun(batchData({ id: `batch_${"b".repeat(24)}` }));
57+
} catch (error) {
58+
caught = error;
59+
}
60+
61+
// The control-plane `instanceof` catch (the P2002→422 path the webapp uses) must see it.
62+
expect(caught instanceof Prisma.PrismaClientKnownRequestError).toBe(true);
63+
const known = caught as Prisma.PrismaClientKnownRequestError;
64+
expect(known.code).toBe("P2002");
65+
// code/message/meta are preserved through the normalization.
66+
expect(typeof known.message).toBe("string");
67+
expect(known.message.length).toBeGreaterThan(0);
68+
expect(known.clientVersion).toBeTruthy();
69+
}
70+
);
71+
72+
heteroRunOpsPostgresTest(
73+
"a NEW-client P2002 inside runInTransaction is also normalized to the control-plane class",
74+
async ({ prisma17 }) => {
75+
const store = makeDedicatedStore(prisma17);
76+
77+
await store.createBatchTaskRun(batchData({ id: `batch_${"c".repeat(24)}` }));
78+
79+
let caught: unknown;
80+
try {
81+
await store.runInTransaction(undefined, async (txStore) => {
82+
await txStore.createBatchTaskRun(batchData({ id: `batch_${"d".repeat(24)}` }));
83+
});
84+
} catch (error) {
85+
caught = error;
86+
}
87+
88+
expect(caught instanceof Prisma.PrismaClientKnownRequestError).toBe(true);
89+
expect((caught as Prisma.PrismaClientKnownRequestError).code).toBe("P2002");
90+
}
91+
);
92+
93+
heteroRunOpsPostgresTest(
94+
"a successful NEW-client write is untouched by the normalization wrapper",
95+
async ({ prisma17 }) => {
96+
const store = makeDedicatedStore(prisma17);
97+
98+
const created = await store.createBatchTaskRun(batchData({ id: `batch_${"e".repeat(24)}` }));
99+
100+
expect(created.id).toBe(`batch_${"e".repeat(24)}`);
101+
expect(created.friendlyId).toBe("batch_dup_friendly");
102+
}
103+
);
104+
});

internal-packages/run-store/src/PostgresRunStore.ts

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,22 +346,166 @@ const WAITPOINT_DEDICATED: DedicatedRelationSpec = {
346346
completedExecutionSnapshots: hydrateCompletedExecutionSnapshots,
347347
};
348348

349+
// Cross-generation Prisma error normalization.
350+
//
351+
// The store can be backed by the control-plane `@trigger.dev/database` client OR the
352+
// run-ops `@internal/run-ops-database` client. Each is a SEPARATELY generated client with
353+
// its own copy of the Prisma runtime, so each has its OWN `PrismaClientKnownRequestError`
354+
// class object (identical code, distinct module identity). A P2002 from the run-ops client
355+
// is therefore NOT `instanceof` the control-plane class — so the webapp's uniform
356+
// `error instanceof Prisma.PrismaClientKnownRequestError` P2002→422 conversion is skipped and
357+
// a raw 500 escapes. The store normalizes at its write boundary: any foreign
358+
// known-request-error is re-thrown as the control-plane class so every routed-write caller's
359+
// `instanceof` works regardless of which client raised it.
360+
361+
// `instanceof` can't detect a foreign generation's class, so key on the runtime `name` the
362+
// Prisma runtime stamps on every generation plus a string `code` (the P-code).
363+
function isForeignPrismaKnownRequestError(
364+
error: unknown
365+
): error is {
366+
name: string;
367+
message: string;
368+
code: string;
369+
meta?: unknown;
370+
clientVersion?: string;
371+
} {
372+
return (
373+
typeof error === "object" &&
374+
error !== null &&
375+
(error as { name?: unknown }).name === "PrismaClientKnownRequestError" &&
376+
typeof (error as { code?: unknown }).code === "string" &&
377+
!(error instanceof Prisma.PrismaClientKnownRequestError)
378+
);
379+
}
380+
381+
// Native + non-known-request errors are returned unchanged (caller re-throws the result).
382+
function normalizeRunOpsError(error: unknown): unknown {
383+
if (!isForeignPrismaKnownRequestError(error)) {
384+
return error;
385+
}
386+
return new Prisma.PrismaClientKnownRequestError(error.message, {
387+
code: error.code,
388+
clientVersion: error.clientVersion ?? "unknown",
389+
meta: error.meta as Record<string, unknown> | undefined,
390+
});
391+
}
392+
393+
// Only these Prisma-model delegates carry the create/update/upsert writes that raise P2002;
394+
// `$queryRaw`/`$executeRaw`/`$transaction` are left untouched (raw queries here never raise a
395+
// duplicate-key, and wrapping their tagged-template/callback contract would break it).
396+
const RUN_OPS_DELEGATE_KEYS: ReadonlySet<string> = new Set([
397+
"taskRun",
398+
"taskRunAttempt",
399+
"taskRunExecutionSnapshot",
400+
"taskRunWaitpoint",
401+
"taskRunCheckpoint",
402+
"checkpoint",
403+
"checkpointRestoreEvent",
404+
"taskRunDependency",
405+
"waitpoint",
406+
"completedWaitpoint",
407+
"waitpointRunConnection",
408+
"batchTaskRun",
409+
"batchTaskRunItem",
410+
]);
411+
412+
// Every method call on a delegate rewrites ONLY its rejection reason; success is untouched.
413+
function wrapDelegateForErrorNormalization<D extends object>(delegate: D): D {
414+
return new Proxy(delegate, {
415+
get(target, prop, receiver) {
416+
const value = Reflect.get(target, prop, receiver);
417+
if (typeof value !== "function") {
418+
return value;
419+
}
420+
return (...args: unknown[]) => {
421+
let result: unknown;
422+
try {
423+
result = (value as (...a: unknown[]) => unknown).apply(target, args);
424+
} catch (error) {
425+
throw normalizeRunOpsError(error);
426+
}
427+
// Delegate methods return a thenable PrismaPromise; rewrite its rejection only.
428+
if (result != null && typeof (result as { then?: unknown }).then === "function") {
429+
return (result as Promise<unknown>).then(undefined, (error) => {
430+
throw normalizeRunOpsError(error);
431+
});
432+
}
433+
return result;
434+
};
435+
},
436+
});
437+
}
438+
439+
// Model delegates are wrapped; `$transaction` wraps its tx client so inner writes normalize
440+
// too; every other property (incl. `$queryRaw`/`$executeRaw`) passes through unchanged.
441+
export function wrapRunOpsClientForErrorNormalization<C extends RunOpsCapableClient>(client: C): C {
442+
// Some tests inject a non-object fake (or nothing) as the client; only a real client can be
443+
// proxied, and only a real client raises the foreign known-request-errors we normalize.
444+
if (client == null || (typeof client !== "object" && typeof client !== "function")) {
445+
return client;
446+
}
447+
const delegateCache = new Map<string, unknown>();
448+
return new Proxy(client, {
449+
get(target, prop, receiver) {
450+
if (typeof prop === "string" && RUN_OPS_DELEGATE_KEYS.has(prop)) {
451+
const cached = delegateCache.get(prop);
452+
if (cached) {
453+
return cached;
454+
}
455+
const delegate = Reflect.get(target, prop, receiver);
456+
if (delegate == null || typeof delegate !== "object") {
457+
return delegate;
458+
}
459+
const wrapped = wrapDelegateForErrorNormalization(delegate as object);
460+
delegateCache.set(prop, wrapped);
461+
return wrapped;
462+
}
463+
464+
if (prop === "$transaction") {
465+
const original = Reflect.get(target, prop, receiver);
466+
if (typeof original !== "function") {
467+
return original;
468+
}
469+
return (fnOrArray: unknown, ...rest: unknown[]) => {
470+
// Interactive (callback) form: wrap the tx client so inner writes normalize too.
471+
if (typeof fnOrArray === "function") {
472+
const wrappedFn = (tx: RunOpsCapableClient) =>
473+
(fnOrArray as (t: RunOpsCapableClient) => unknown)(
474+
wrapRunOpsClientForErrorNormalization(tx)
475+
);
476+
return (original as (...a: unknown[]) => unknown).call(target, wrappedFn, ...rest);
477+
}
478+
return (original as (...a: unknown[]) => unknown).call(target, fnOrArray, ...rest);
479+
};
480+
}
481+
482+
return Reflect.get(target, prop, receiver);
483+
},
484+
}) as C;
485+
}
486+
349487
/**
350488
* Typed write layer for the task-run row, backed by the `taskRun` Prisma model.
351489
*
352490
* Each method is a verbatim relocation of the Prisma statement that lives at a
353491
* specific call site today. Methods write through `(tx ?? this.prisma).taskRun`
354-
* so callers can opt into an existing transaction. Errors (including unique
355-
* constraint violations) propagate to the caller unchanged.
492+
* so callers can opt into an existing transaction. Errors surface with unique
493+
* constraint violations (P2002 etc.) normalized to the control-plane
494+
* `Prisma.PrismaClientKnownRequestError` class (see `wrapRunOpsClientForErrorNormalization`),
495+
* so `instanceof Prisma.PrismaClientKnownRequestError` works regardless of which
496+
* generated client backs the store.
356497
*/
357498
export class PostgresRunStore implements RunStore {
358499
private readonly prisma: RunOpsCapableClient;
359500
private readonly readOnlyPrisma: RunOpsCapableClient;
360501
private readonly schemaVariant: RunStoreSchemaVariant;
361502

362503
constructor(options: PostgresRunStoreOptions) {
363-
this.prisma = options.prisma;
364-
this.readOnlyPrisma = options.readOnlyPrisma;
504+
// Normalize foreign (run-ops-generation) Prisma known-request-errors to the control-plane
505+
// class at the write boundary so callers' `instanceof Prisma.PrismaClientKnownRequestError`
506+
// (P2002→422) works regardless of which generated client backs the store.
507+
this.prisma = wrapRunOpsClientForErrorNormalization(options.prisma);
508+
this.readOnlyPrisma = wrapRunOpsClientForErrorNormalization(options.readOnlyPrisma);
365509
this.schemaVariant = options.schemaVariant ?? "legacy";
366510
}
367511

0 commit comments

Comments
 (0)