Skip to content

Commit 6e1b607

Browse files
committed
test(run-ops-database): verify real bidirectional schema parity
The parity test only read the dedicated run-ops schema and matched model headers with regexes, so it never compared columns against the control-plane schema and could not detect a run-subgraph scalar column that diverged between the two physical schemas. Parse both schemas and assert bidirectional scalar-column parity (type, nullability, array-ness, default) across the run-subgraph models, scoped to those models so an unrelated control-plane edit can't break it, and fail on any unparsed field line so a format change can't silently bypass the check.
1 parent 4021096 commit 6e1b607

1 file changed

Lines changed: 217 additions & 45 deletions

File tree

internal-packages/run-ops-database/prisma/schema.parity.test.ts

Lines changed: 217 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,24 @@ import { describe, expect, it } from "vitest";
22
import { readFileSync } from "node:fs";
33
import { resolve } from "node:path";
44

5-
// Asserts every scalar column of the run-subgraph models in the control-plane
6-
// schema also exists in the dedicated schema (so the dedicated DB can hold the
7-
// same row shape), and that the dedicated schema contains NO reference to a
8-
// control-plane model name.
9-
const CONTROL_PLANE_MODELS = [
5+
// Parses both the control-plane schema (`@trigger.dev/database`) and this
6+
// dedicated run-ops schema, then diffs every SCALAR field of each run-subgraph
7+
// model BIDIRECTIONALLY: a field present in either schema must exist in the
8+
// other with matching type/nullability/array-ness/`@default`, since a run row
9+
// must be writable to either physical database.
10+
//
11+
// Relation fields are excluded by design: the dedicated schema drops
12+
// control-plane `@relation`s to control-plane-owned models and replaces the
13+
// implicit many-to-many waitpoint relations with explicit FK-free join models
14+
// (`TaskRunWaitpoint`, `WaitpointRunConnection`, `CompletedWaitpoint`). Only
15+
// the scalar FK columns backing those relations (e.g. `projectId`) are
16+
// compared; the relation object fields (e.g. `project`) are skipped.
17+
18+
const CONTROL_PLANE_SCHEMA_PATH = "../../database/prisma/schema.prisma";
19+
const DEDICATED_SCHEMA_PATH = "./schema.prisma";
20+
21+
// Must never appear as a relation target in the dedicated schema.
22+
const CONTROL_PLANE_ONLY_MODELS = [
1023
"Organization",
1124
"OrgMember",
1225
"Project",
@@ -19,73 +32,232 @@ const CONTROL_PLANE_MODELS = [
1932
"TaskQueue",
2033
];
2134

22-
function readSchema(rel: string) {
35+
// Must have every scalar column reproduced in the dedicated schema.
36+
const RUN_SUBGRAPH_MODELS = [
37+
"TaskRun",
38+
"TaskRunAttempt",
39+
"TaskRunExecutionSnapshot",
40+
"TaskRunWaitpoint",
41+
"TaskRunCheckpoint",
42+
"CheckpointRestoreEvent",
43+
"TaskRunTag",
44+
"Waitpoint",
45+
"WaitpointTag",
46+
"BatchTaskRun",
47+
"TaskRunDependency",
48+
"BatchTaskRunItem",
49+
"BatchTaskRunError",
50+
"Checkpoint",
51+
];
52+
53+
function readSchema(rel: string): string {
2354
return readFileSync(resolve(__dirname, rel), "utf8");
2455
}
2556

26-
// Prisma comments (`///` docs and `//` lines) may legitimately mention
27-
// control-plane model names in prose, which would false-match the drift
28-
// regexes below. Strip them so parity assertions only see real schema syntax.
29-
function stripComments(schema: string) {
30-
return schema.replace(/\/\/.*$/gm, "");
57+
// Strips `/* */` block comments and `//`/`///` line comments so prose mentioning
58+
// model names can't false-match below. (Neither schema has `/*` inside a string.)
59+
function stripComments(schema: string): string {
60+
return schema.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
61+
}
62+
63+
interface FieldInfo {
64+
type: string;
65+
optional: boolean;
66+
array: boolean;
67+
default: string | null;
68+
}
69+
70+
type ModelFields = Map<string, FieldInfo>;
71+
72+
// Prisma model blocks don't nest, so a lazy match to the next `}`-only line suffices.
73+
function extractModelBlocks(schema: string): Map<string, string> {
74+
const blocks = new Map<string, string>();
75+
const re = /^model\s+(\w+)\s*\{([\s\S]*?)\n\}/gm;
76+
let match: RegExpExecArray | null;
77+
while ((match = re.exec(schema))) {
78+
blocks.set(match[1], match[2]);
79+
}
80+
return blocks;
81+
}
82+
83+
// Raw argument of `@default(...)`, honoring nested parens (`@default(cuid())`). Null if absent.
84+
function extractDefault(attrs: string): string | null {
85+
const marker = "@default(";
86+
const idx = attrs.indexOf(marker);
87+
if (idx === -1) return null;
88+
const start = idx + marker.length;
89+
let depth = 0;
90+
for (let i = start; i < attrs.length; i++) {
91+
if (attrs[i] === "(") {
92+
depth++;
93+
} else if (attrs[i] === ")") {
94+
if (depth === 0) return attrs.slice(start, i);
95+
depth--;
96+
}
97+
}
98+
return attrs.slice(start);
99+
}
100+
101+
// Excludes relation fields: anything carrying `@relation`, plus back-relation fields
102+
// (e.g. `checkpoints Checkpoint[]`) identified by their type being another model name.
103+
// `unparsed` collects any content line the field regex fails to match, so a future
104+
// multi-line field can never silently vanish from the diff.
105+
function parseScalarFields(
106+
body: string,
107+
modelNames: Set<string>
108+
): { fields: ModelFields; unparsed: string[] } {
109+
const fields: ModelFields = new Map();
110+
const unparsed: string[] = [];
111+
for (const rawLine of body.split("\n")) {
112+
const line = rawLine.trim();
113+
if (!line || line.startsWith("@@")) continue;
114+
const match = line.match(/^(\w+)\s+([A-Za-z_]\w*)(\[\])?(\?)?\s*(.*)$/);
115+
if (!match) {
116+
unparsed.push(line);
117+
continue;
118+
}
119+
const [, name, type, arrayMark, optionalMark, rest] = match;
120+
if (rest.includes("@relation")) continue;
121+
if (modelNames.has(type)) continue;
122+
fields.set(name, {
123+
type,
124+
array: arrayMark === "[]",
125+
optional: optionalMark === "?",
126+
default: extractDefault(rest),
127+
});
128+
}
129+
return { fields, unparsed };
130+
}
131+
132+
function parseSchema(schema: string) {
133+
const stripped = stripComments(schema);
134+
const modelBlocks = extractModelBlocks(stripped);
135+
const modelNames = new Set(modelBlocks.keys());
136+
const models = new Map<string, ModelFields>();
137+
const unparsed: string[] = [];
138+
for (const [name, body] of modelBlocks) {
139+
const parsed = parseScalarFields(body, modelNames);
140+
models.set(name, parsed.fields);
141+
for (const line of parsed.unparsed) {
142+
unparsed.push(`${name}: ${line}`);
143+
}
144+
}
145+
return { models, modelNames, unparsed };
146+
}
147+
148+
function describeField(field: FieldInfo | undefined): string {
149+
if (!field) return "<missing>";
150+
const array = field.array ? "[]" : "";
151+
const optional = field.optional ? "?" : "";
152+
const withDefault = field.default !== null ? ` @default(${field.default})` : "";
153+
return `${field.type}${array}${optional}${withDefault}`;
31154
}
32155

156+
const controlPlane = parseSchema(readSchema(CONTROL_PLANE_SCHEMA_PATH));
157+
const dedicated = parseSchema(readSchema(DEDICATED_SCHEMA_PATH));
158+
33159
describe("dedicated run-ops schema parity", () => {
160+
it("includes all 14 run-subgraph models in both schemas", () => {
161+
for (const model of RUN_SUBGRAPH_MODELS) {
162+
expect(Array.from(dedicated.modelNames)).toContain(model);
163+
expect(Array.from(controlPlane.modelNames)).toContain(model);
164+
}
165+
});
166+
167+
it("fails on any content line the field parser doesn't recognise (no silent skips)", () => {
168+
// Scope the control-plane check to run-subgraph models so an unrelated control-plane schema
169+
// edit can't break this run-ops test; the dedicated schema contains only run-ops models.
170+
const inRunSubgraph = (entry: string) =>
171+
RUN_SUBGRAPH_MODELS.some((model) => entry.startsWith(`${model}: `));
172+
expect(controlPlane.unparsed.filter(inRunSubgraph)).toEqual([]);
173+
expect(dedicated.unparsed).toEqual([]);
174+
});
175+
176+
it("reproduces every scalar column of each run-subgraph model bidirectionally with matching type/nullability/array/default", () => {
177+
const mismatches: string[] = [];
178+
179+
for (const modelName of RUN_SUBGRAPH_MODELS) {
180+
const controlFields = controlPlane.models.get(modelName);
181+
const dedicatedFields = dedicated.models.get(modelName);
182+
if (!controlFields || !dedicatedFields) {
183+
mismatches.push(`${modelName}: model not found in one of the two schemas`);
184+
continue;
185+
}
186+
187+
const fieldNames = new Set([...controlFields.keys(), ...dedicatedFields.keys()]);
188+
for (const fieldName of fieldNames) {
189+
const controlField = controlFields.get(fieldName);
190+
const dedicatedField = dedicatedFields.get(fieldName);
191+
192+
if (!controlField) {
193+
mismatches.push(
194+
`${modelName}.${fieldName}: dedicated has ${describeField(
195+
dedicatedField
196+
)} but the control-plane schema has no such scalar field`
197+
);
198+
continue;
199+
}
200+
if (!dedicatedField) {
201+
mismatches.push(
202+
`${modelName}.${fieldName}: control-plane has ${describeField(
203+
controlField
204+
)} but the dedicated schema has no such scalar field`
205+
);
206+
continue;
207+
}
208+
209+
const matches =
210+
dedicatedField.type === controlField.type &&
211+
dedicatedField.array === controlField.array &&
212+
dedicatedField.optional === controlField.optional &&
213+
dedicatedField.default === controlField.default;
214+
215+
if (!matches) {
216+
mismatches.push(
217+
`${modelName}.${fieldName}: control-plane=${describeField(
218+
controlField
219+
)} dedicated=${describeField(dedicatedField)}`
220+
);
221+
}
222+
}
223+
}
224+
225+
expect(mismatches).toEqual([]);
226+
});
227+
34228
it("references no control-plane model as a relation target", () => {
35-
const dedicated = stripComments(readSchema("./schema.prisma"));
36-
for (const model of CONTROL_PLANE_MODELS) {
229+
const dedicatedText = stripComments(readSchema(DEDICATED_SCHEMA_PATH));
230+
for (const model of CONTROL_PLANE_ONLY_MODELS) {
37231
// A relation target appears as ` fieldName Model @relation(...)`. A bare
38232
// scalar column like `projectId String` is fine; the model TYPE must be absent.
39233
const relationTarget = new RegExp(
40234
`@relation[^\\n]*\\b${model}\\b|\\b${model}\\b[^\\n]*@relation`
41235
);
42-
expect(dedicated).not.toMatch(relationTarget);
43-
expect(dedicated).not.toMatch(new RegExp(`\\s${model}(\\?|\\[\\])?\\s`));
44-
}
45-
});
46-
47-
it("includes all 14 run-subgraph models", () => {
48-
const dedicated = readSchema("./schema.prisma");
49-
for (const m of [
50-
"TaskRun",
51-
"TaskRunAttempt",
52-
"TaskRunExecutionSnapshot",
53-
"TaskRunWaitpoint",
54-
"TaskRunCheckpoint",
55-
"CheckpointRestoreEvent",
56-
"TaskRunTag",
57-
"Waitpoint",
58-
"WaitpointTag",
59-
"BatchTaskRun",
60-
"TaskRunDependency",
61-
"BatchTaskRunItem",
62-
"BatchTaskRunError",
63-
"Checkpoint",
64-
]) {
65-
expect(dedicated).toMatch(new RegExp(`model ${m} \\{`));
236+
expect(dedicatedText).not.toMatch(relationTarget);
237+
expect(dedicatedText).not.toMatch(new RegExp(`\\s${model}(\\?|\\[\\])?\\s`));
66238
}
67239
});
68240

69241
it("keeps the group-(A) waitpoint-block references FK-FREE (scalar columns / explicit FK-free join models)", () => {
70-
const dedicated = stripComments(readSchema("./schema.prisma"));
242+
const dedicatedText = stripComments(readSchema(DEDICATED_SCHEMA_PATH));
71243
// TaskRunWaitpoint must NOT carry a `@relation` to Waitpoint/TaskRun/BatchTaskRun.
72-
const trw = dedicated.match(/model TaskRunWaitpoint \{[\s\S]*?\n\}/)![0];
244+
const trw = dedicatedText.match(/model TaskRunWaitpoint \{[\s\S]*?\n\}/)![0];
73245
expect(trw).not.toMatch(/@relation/);
74246
expect(trw).toMatch(/waitpointId\s+String/);
75247
expect(trw).toMatch(/taskRunId\s+String/);
76248
// The two implicit M2M sets are replaced by explicit FK-free join models.
77-
expect(dedicated).toMatch(/model WaitpointRunConnection \{/);
78-
expect(dedicated).toMatch(/model CompletedWaitpoint \{/);
79-
const wrc = dedicated.match(/model WaitpointRunConnection \{[\s\S]*?\n\}/)![0];
249+
expect(dedicatedText).toMatch(/model WaitpointRunConnection \{/);
250+
expect(dedicatedText).toMatch(/model CompletedWaitpoint \{/);
251+
const wrc = dedicatedText.match(/model WaitpointRunConnection \{[\s\S]*?\n\}/)![0];
80252
expect(wrc).not.toMatch(/@relation/);
81253
// Waitpoint completion back-refs are scalar, not relations.
82-
const wp = dedicated.match(/model Waitpoint \{[\s\S]*?\n\}/)![0];
254+
const wp = dedicatedText.match(/model Waitpoint \{[\s\S]*?\n\}/)![0];
83255
expect(wp).not.toMatch(/completedByTaskRun\s+TaskRun\s*\?\s*@relation/);
84256
});
85257

86258
it("keeps the group-(B) co-resident references as real FKs (e.g. TaskRunAttempt.taskRun)", () => {
87-
const dedicated = stripComments(readSchema("./schema.prisma"));
88-
const attempt = dedicated.match(/model TaskRunAttempt \{[\s\S]*?\n\}/)![0];
259+
const dedicatedText = stripComments(readSchema(DEDICATED_SCHEMA_PATH));
260+
const attempt = dedicatedText.match(/model TaskRunAttempt \{[\s\S]*?\n\}/)![0];
89261
// The attempt->run relation stays a real FK (always co-resident).
90262
expect(attempt).toMatch(/taskRun\s+TaskRun\s+@relation/);
91263
});

0 commit comments

Comments
 (0)