Skip to content

Commit 707dff7

Browse files
committed
test: raw-query golden harness for @prisma/adapter-pg (TRI-13039 spike)
Spike evidence only, not for merge. Adds a testcontainers harness that runs the same raw queries through the current Rust-engine Prisma client and an @prisma/adapter-pg client against the same Postgres, plus a tracing gate. - Deliverable A: engine spans (prisma:client:operation, prisma:engine:db_query, prisma:engine:connection) survive the adapter under driverAdapters-alone. - Deliverable B: 10/11 raw-result shapes byte-identical; only unqualified SQL on a non-public schema diverges (#28128), inert for the public-schema prod DBs. Enables the driverAdapters preview flag on both schemas (regen required, not committed).
1 parent c526528 commit 707dff7

7 files changed

Lines changed: 487 additions & 22 deletions

File tree

internal-packages/database/prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ generator client {
88
provider = "prisma-client-js"
99
output = "../generated/prisma"
1010
binaryTargets = ["native", "debian-openssl-1.1.x"]
11-
previewFeatures = ["metrics"]
11+
previewFeatures = ["metrics", "driverAdapters"]
1212
}
1313

1414
model User {

internal-packages/run-ops-database/prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ generator client {
77
provider = "prisma-client-js"
88
output = "../generated/run-ops"
99
binaryTargets = ["native", "debian-openssl-1.1.x"]
10-
previewFeatures = ["metrics"]
10+
previewFeatures = ["metrics", "driverAdapters"]
1111
}
1212

1313
// ─────────────────────────────────────────────────────────────────────────────

internal-packages/testcontainers/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@
2020
},
2121
"devDependencies": {
2222
"@internal/run-ops-database": "workspace:*",
23+
"@opentelemetry/instrumentation": "0.218.0",
24+
"@opentelemetry/sdk-trace-base": "2.7.1",
25+
"@opentelemetry/sdk-trace-node": "2.7.1",
26+
"@prisma/adapter-pg": "6.14.0",
27+
"@prisma/instrumentation": "6.14.0",
2328
"@testcontainers/postgresql": "^11.14.0",
29+
"@types/pg": "^8.11.10",
2430
"@testcontainers/redis": "^11.14.0",
2531
"@trigger.dev/core": "workspace:*",
32+
"pg": "8.15.6",
2633
"std-env": "^3.9.0",
2734
"testcontainers": "^11.14.0",
2835
"tinyexec": "^0.3.0"
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { writeFileSync } from "node:fs";
2+
import { Prisma } from "@trigger.dev/database";
3+
import { expect } from "vitest";
4+
import { postgresTest } from "./index";
5+
import {
6+
createAdapterClient,
7+
createClientPair,
8+
createRustClient,
9+
describe,
10+
runShape,
11+
type ShapeCase,
12+
type ShapeResult,
13+
} from "./adapterGolden";
14+
15+
function safeWrite(path: string, contents: string) {
16+
try {
17+
writeFileSync(path, contents);
18+
} catch {
19+
void 0;
20+
}
21+
}
22+
23+
const shapes: ShapeCase[] = [
24+
{
25+
id: 1,
26+
name: "text[] param with explicit cast (= ANY(${ids}::text[]))",
27+
callSite: "PostgresRunStore.ts L2159-2161 (documented binding workaround)",
28+
upstream: "#24338",
29+
setup: async (c) => {
30+
await c.$executeRawUnsafe(`CREATE TABLE g1 (id text)`);
31+
await c.$executeRawUnsafe(`INSERT INTO g1 (id) VALUES ('a'),('b'),('c')`);
32+
},
33+
run: (c) => {
34+
const ids = ["a", "c"];
35+
return c.$queryRaw`SELECT id FROM g1 WHERE id = ANY(${ids}::text[]) ORDER BY id`;
36+
},
37+
},
38+
{
39+
id: 2,
40+
name: "array results (array_agg + text[] column)",
41+
callSite: "text[] columns / array_agg readers",
42+
upstream: "#27823",
43+
setup: async (c) => {
44+
await c.$executeRawUnsafe(`CREATE TABLE g2 (id text, tags text[])`);
45+
await c.$executeRawUnsafe(`INSERT INTO g2 (id, tags) VALUES ('x', ARRAY['a','b'])`);
46+
},
47+
run: (c) => c.$queryRaw`SELECT tags, array_agg(id ORDER BY id) AS ids FROM g2 GROUP BY tags`,
48+
},
49+
{
50+
id: 3,
51+
name: "bigint from COUNT(*)",
52+
callSite: "PostgresRunStore.ts:2162,2171; DeploymentListPresenter.server.ts:322,168",
53+
upstream: "#23926",
54+
setup: async (c) => {
55+
await c.$executeRawUnsafe(`CREATE TABLE g3 (id int)`);
56+
await c.$executeRawUnsafe(`INSERT INTO g3 (id) VALUES (1),(2),(3)`);
57+
},
58+
run: (c) => c.$queryRaw`SELECT COUNT(*) AS c FROM g3`,
59+
},
60+
{
61+
id: 4,
62+
name: "bigint ns timestamp (keyset pagination)",
63+
callSite: "taskEventStore.server.ts L339-386",
64+
run: (c) => c.$queryRaw`SELECT 1723058400000000000::int8 AS t`,
65+
},
66+
{
67+
id: 5,
68+
name: "NUMERIC -> Prisma.Decimal",
69+
callSite: "QueueListPresenter.server.ts:383",
70+
setup: async (c) => {
71+
await c.$executeRawUnsafe(`CREATE TABLE g5 (n numeric(30,4))`);
72+
await c.$executeRawUnsafe(`INSERT INTO g5 (n) VALUES ('12345.6789')`);
73+
},
74+
run: (c) => c.$queryRaw`SELECT n FROM g5`,
75+
},
76+
{
77+
id: 6,
78+
name: "enum cast (CAST('LOG'::text AS enum))",
79+
callSite: "taskEventStore.server.ts:196,224,274,303,327",
80+
setup: async (c) => {
81+
await c.$executeRawUnsafe(`CREATE TYPE g6_kind AS ENUM ('LOG','SPAN')`);
82+
},
83+
run: (c) => c.$queryRaw`SELECT CAST('LOG'::text AS g6_kind) AS kind`,
84+
},
85+
{
86+
id: 7,
87+
name: "jsonb with cast parameters (to_jsonb(${v}::text/::int))",
88+
callSite: "dashboardPreferences.server.ts:145,175",
89+
upstream: "#24338",
90+
run: (c) => {
91+
const theme = "dark";
92+
const contrast = 1;
93+
return c.$queryRaw`
94+
SELECT jsonb_set(
95+
jsonb_set('{}'::jsonb, '{theme}', to_jsonb(${theme}::text)),
96+
'{contrast}', to_jsonb(${contrast}::int)
97+
) AS prefs`;
98+
},
99+
},
100+
{
101+
id: 8,
102+
name: "timestamptz round-trip (Date param)",
103+
callSite: "Date -> timestamptz writers",
104+
upstream: "#28629",
105+
run: (c) => {
106+
const d = new Date("2026-08-07T12:34:56.789Z");
107+
return c.$queryRaw`SELECT ${d}::timestamptz AS ts`;
108+
},
109+
},
110+
{
111+
id: 9,
112+
name: "IN with varying arity",
113+
callSite: "boundedIn() (TRI-4480)",
114+
upstream: "#21803",
115+
setup: async (c) => {
116+
await c.$executeRawUnsafe(`CREATE TABLE g9 (id text)`);
117+
await c.$executeRawUnsafe(`INSERT INTO g9 (id) VALUES ('a'),('b'),('c'),('d')`);
118+
},
119+
run: async (c) => {
120+
const one = await c.$queryRaw`SELECT id FROM g9 WHERE id IN (${"a"}) ORDER BY id`;
121+
const three =
122+
await c.$queryRaw`SELECT id FROM g9 WHERE id IN (${"a"},${"c"},${"d"}) ORDER BY id`;
123+
return { arity1: one, arity3: three };
124+
},
125+
},
126+
{
127+
id: 11,
128+
name: "nulls, empty result set, zero-row RETURNING",
129+
callSite: "classic divergence points",
130+
setup: async (c) => {
131+
await c.$executeRawUnsafe(`CREATE TABLE g11 (id text)`);
132+
await c.$executeRawUnsafe(`INSERT INTO g11 (id) VALUES ('a')`);
133+
},
134+
run: async (c) => {
135+
const nulls = await c.$queryRaw`SELECT NULL::text AS a, NULL::int AS b`;
136+
const empty = await c.$queryRaw`SELECT id FROM g11 WHERE 1=0`;
137+
const returning = await c.$queryRaw`UPDATE g11 SET id = id WHERE 1=0 RETURNING id`;
138+
return { nulls, empty, returning };
139+
},
140+
},
141+
];
142+
143+
function renderTable(results: ShapeResult[]): string {
144+
const rows = results.map((r) => {
145+
const status = r.identical ? "IDENTICAL" : r.adapterErrored ? "ADAPTER ERROR" : "DIVERGES";
146+
return [
147+
`### Shape ${r.id}: ${r.name}`,
148+
`- call site: ${r.callSite}${r.upstream ? ` (upstream ${r.upstream})` : ""}`,
149+
`- result: **${status}**`,
150+
`- rust: \`${r.rust}\``,
151+
`- adapter: \`${r.adapter}\``,
152+
"",
153+
].join("\n");
154+
});
155+
const pass = results.filter((r) => r.identical).length;
156+
return [`## Golden matrix: ${pass}/${results.length} identical`, "", ...rows].join("\n");
157+
}
158+
159+
postgresTest(
160+
"Deliverable B — raw-result equivalence matrix",
161+
async ({ postgresContainer }) => {
162+
const pair = await createClientPair(postgresContainer.getConnectionUri());
163+
const results: ShapeResult[] = [];
164+
165+
try {
166+
for (const shape of shapes) {
167+
const result = await runShape(pair, shape);
168+
results.push(result);
169+
const tag = result.identical ? "OK " : result.adapterErrored ? "ERR " : "DIFF";
170+
console.log(`[TRI-13039][B] ${tag} shape ${result.id}: ${result.name}`);
171+
if (!result.identical) {
172+
console.log(` rust: ${result.rust}`);
173+
console.log(` adapter: ${result.adapter}`);
174+
}
175+
}
176+
} finally {
177+
await pair.disconnect();
178+
}
179+
180+
const md = renderTable(results);
181+
console.log("\n" + md);
182+
safeWrite("/Users/eric/code/triggerdotdev/isolated/adapter-pg-spike-work/golden-matrix.md", md);
183+
184+
for (const r of results) {
185+
expect(r.rust, `shape ${r.id} (${r.name}) must be byte-identical`).toBe(r.adapter);
186+
}
187+
},
188+
180000
189+
);
190+
191+
postgresTest(
192+
"Deliverable B shape 10 — unqualified SQL relying on search_path / {schema} (#28128)",
193+
async ({ postgresContainer }) => {
194+
const baseUri = postgresContainer.getConnectionUri();
195+
196+
const admin = createRustClient(baseUri);
197+
await admin.$executeRawUnsafe(`CREATE SCHEMA s1`);
198+
await admin.$executeRawUnsafe(`CREATE TABLE s1.t (id text)`);
199+
await admin.$executeRawUnsafe(`INSERT INTO s1.t (id) VALUES ('inschema')`);
200+
await admin.$executeRawUnsafe(`CREATE TABLE public.pt (id text)`);
201+
await admin.$executeRawUnsafe(`INSERT INTO public.pt (id) VALUES ('inpublic')`);
202+
await admin.$disconnect();
203+
204+
const capture = async (fn: () => Promise<unknown>) => {
205+
try {
206+
return { ok: true as const, desc: describe(await fn()) };
207+
} catch (err: any) {
208+
return {
209+
ok: false as const,
210+
desc: `${err?.code}: ${String(err?.message).split("\n").pop()}`,
211+
};
212+
}
213+
};
214+
215+
const nonPublicAdapter = createAdapterClient(baseUri, "s1");
216+
const publicAdapter = createAdapterClient(baseUri, "public");
217+
try {
218+
const nonPublic = await capture(() =>
219+
nonPublicAdapter.$queryRaw(Prisma.sql([`SELECT id FROM t ORDER BY id`]))
220+
);
221+
const publicSchema = await capture(() =>
222+
publicAdapter.$queryRaw(Prisma.sql([`SELECT id FROM pt ORDER BY id`]))
223+
);
224+
225+
console.log(`[TRI-13039][B] shape 10 adapter {schema:s1} unqualified: ${nonPublic.desc}`);
226+
console.log(
227+
`[TRI-13039][B] shape 10 adapter {schema:public} unqualified: ${publicSchema.desc}`
228+
);
229+
safeWrite(
230+
"/Users/eric/code/triggerdotdev/isolated/adapter-pg-spike-work/golden-shape10.md",
231+
[
232+
"## Shape 10: unqualified SQL relying on search_path (#28128)",
233+
"",
234+
"The adapter's `{schema}` option does NOT set a session search_path for raw SQL.",
235+
`- {schema:s1} + unqualified \`t\` (non-public): ${nonPublic.ok ? "OK" : "FAILS"} -> \`${nonPublic.desc}\``,
236+
`- {schema:public} + unqualified \`pt\` (public): ${publicSchema.ok ? "OK" : "FAILS"} -> \`${publicSchema.desc}\``,
237+
"",
238+
"Prod impact: both DATABASE_URL and RUN_OPS_DATABASE_URL use ?schema=public, so unqualified",
239+
"raw SQL resolves fine under the adapter. Only a non-public schema would break.",
240+
].join("\n")
241+
);
242+
243+
expect(nonPublic.ok, "non-public schema breaks unqualified raw SQL (confirms #28128)").toBe(
244+
false
245+
);
246+
expect(publicSchema.ok, "public schema resolves unqualified raw SQL under the adapter").toBe(
247+
true
248+
);
249+
expect(publicSchema.desc).toBe(`[{id: "inpublic"}]`);
250+
} finally {
251+
await Promise.allSettled([nonPublicAdapter.$disconnect(), publicAdapter.$disconnect()]);
252+
}
253+
},
254+
180000
255+
);
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { PrismaClient, Prisma } from "@trigger.dev/database";
2+
import { PrismaPg } from "@prisma/adapter-pg";
3+
4+
export type ClientPair = {
5+
rust: PrismaClient;
6+
adapter: PrismaClient;
7+
disconnect: () => Promise<void>;
8+
};
9+
10+
export function createRustClient(url: string): PrismaClient {
11+
return new PrismaClient({ datasources: { db: { url } } });
12+
}
13+
14+
export function createAdapterClient(url: string, schema?: string): PrismaClient {
15+
const adapter = new PrismaPg({ connectionString: url }, schema ? { schema } : undefined);
16+
return new PrismaClient({ adapter } as unknown as ConstructorParameters<typeof PrismaClient>[0]);
17+
}
18+
19+
export async function createClientPair(url: string): Promise<ClientPair> {
20+
const rust = createRustClient(url);
21+
const adapter = createAdapterClient(url);
22+
return {
23+
rust,
24+
adapter,
25+
disconnect: async () => {
26+
await Promise.allSettled([rust.$disconnect(), adapter.$disconnect()]);
27+
},
28+
};
29+
}
30+
31+
export function describe(value: unknown): string {
32+
if (value === null) return "null";
33+
if (value === undefined) return "undefined";
34+
if (typeof value === "bigint") return `bigint(${value.toString()})`;
35+
if (Prisma.Decimal.isDecimal(value)) return `Decimal(${(value as Prisma.Decimal).toString()})`;
36+
if (value instanceof Date) return `Date(${value.toISOString()})`;
37+
if (Buffer.isBuffer(value)) return `Buffer(${value.toString("hex")})`;
38+
if (Array.isArray(value)) return `[${value.map(describe).join(", ")}]`;
39+
if (typeof value === "object") {
40+
const entries = Object.entries(value as Record<string, unknown>)
41+
.map(([k, v]) => `${k}: ${describe(v)}`)
42+
.join(", ");
43+
return `{${entries}}`;
44+
}
45+
if (typeof value === "string") return `"${value}"`;
46+
return `${typeof value}(${String(value)})`;
47+
}
48+
49+
export function deepEqual(a: unknown, b: unknown): boolean {
50+
return describe(a) === describe(b);
51+
}
52+
53+
export type ShapeCase = {
54+
id: number;
55+
name: string;
56+
callSite: string;
57+
upstream?: string;
58+
setup?: (rust: PrismaClient) => Promise<void>;
59+
run: (client: PrismaClient) => Promise<unknown>;
60+
};
61+
62+
export type ShapeResult = {
63+
id: number;
64+
name: string;
65+
callSite: string;
66+
upstream?: string;
67+
rust: string;
68+
adapter: string;
69+
identical: boolean;
70+
adapterErrored: boolean;
71+
};
72+
73+
async function capture(fn: () => Promise<unknown>): Promise<{ desc: string; errored: boolean }> {
74+
try {
75+
const value = await fn();
76+
return { desc: describe(value), errored: false };
77+
} catch (err) {
78+
const message = err instanceof Error ? err.message : String(err);
79+
return { desc: `ERROR: ${message.split("\n")[0]}`, errored: true };
80+
}
81+
}
82+
83+
export async function runShape(pair: ClientPair, shape: ShapeCase): Promise<ShapeResult> {
84+
if (shape.setup) {
85+
await shape.setup(pair.rust);
86+
}
87+
const rust = await capture(() => shape.run(pair.rust));
88+
const adapter = await capture(() => shape.run(pair.adapter));
89+
return {
90+
id: shape.id,
91+
name: shape.name,
92+
callSite: shape.callSite,
93+
upstream: shape.upstream,
94+
rust: rust.desc,
95+
adapter: adapter.desc,
96+
identical: rust.desc === adapter.desc,
97+
adapterErrored: adapter.errored,
98+
};
99+
}

0 commit comments

Comments
 (0)