265 lines
9.9 KiB
TypeScript
265 lines
9.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
// Parses both the control-plane schema (`@trigger.dev/database`) and this
|
|
// dedicated run-ops schema, then diffs every SCALAR field of each run-subgraph
|
|
// model BIDIRECTIONALLY: a field present in either schema must exist in the
|
|
// other with matching type/nullability/array-ness/`@default`, since a run row
|
|
// must be writable to either physical database.
|
|
//
|
|
// Relation fields are excluded by design: the dedicated schema drops
|
|
// control-plane `@relation`s to control-plane-owned models and replaces the
|
|
// implicit many-to-many waitpoint relations with explicit FK-free join models
|
|
// (`TaskRunWaitpoint`, `WaitpointRunConnection`, `CompletedWaitpoint`). Only
|
|
// the scalar FK columns backing those relations (e.g. `projectId`) are
|
|
// compared; the relation object fields (e.g. `project`) are skipped.
|
|
|
|
const CONTROL_PLANE_SCHEMA_PATH = "../../database/prisma/schema.prisma";
|
|
const DEDICATED_SCHEMA_PATH = "./schema.prisma";
|
|
|
|
// Must never appear as a relation target in the dedicated schema.
|
|
const CONTROL_PLANE_ONLY_MODELS = [
|
|
"Organization",
|
|
"OrgMember",
|
|
"Project",
|
|
"RuntimeEnvironment",
|
|
"User",
|
|
"TaskSchedule",
|
|
"BackgroundWorker",
|
|
"BackgroundWorkerTask",
|
|
"WorkerDeployment",
|
|
"TaskQueue",
|
|
];
|
|
|
|
// Must have every scalar column reproduced in the dedicated schema.
|
|
const RUN_SUBGRAPH_MODELS = [
|
|
"TaskRun",
|
|
"TaskRunAttempt",
|
|
"TaskRunExecutionSnapshot",
|
|
"TaskRunWaitpoint",
|
|
"TaskRunCheckpoint",
|
|
"CheckpointRestoreEvent",
|
|
"TaskRunTag",
|
|
"Waitpoint",
|
|
"WaitpointTag",
|
|
"BatchTaskRun",
|
|
"TaskRunDependency",
|
|
"BatchTaskRunItem",
|
|
"BatchTaskRunError",
|
|
"Checkpoint",
|
|
];
|
|
|
|
function readSchema(rel: string): string {
|
|
return readFileSync(resolve(__dirname, rel), "utf8");
|
|
}
|
|
|
|
// Strips `/* */` block comments and `//`/`///` line comments so prose mentioning
|
|
// model names can't false-match below. (Neither schema has `/*` inside a string.)
|
|
function stripComments(schema: string): string {
|
|
return schema.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
}
|
|
|
|
type FieldInfo = {
|
|
type: string;
|
|
optional: boolean;
|
|
array: boolean;
|
|
default: string | null;
|
|
};
|
|
|
|
type ModelFields = Map<string, FieldInfo>;
|
|
|
|
// Prisma model blocks don't nest, so a lazy match to the next `}`-only line suffices.
|
|
function extractModelBlocks(schema: string): Map<string, string> {
|
|
const blocks = new Map<string, string>();
|
|
const re = /^model\s+(\w+)\s*\{([\s\S]*?)\n\}/gm;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = re.exec(schema))) {
|
|
blocks.set(match[1], match[2]);
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
// Raw argument of `@default(...)`, honoring nested parens (`@default(cuid())`). Null if absent.
|
|
function extractDefault(attrs: string): string | null {
|
|
const marker = "@default(";
|
|
const idx = attrs.indexOf(marker);
|
|
if (idx === -1) return null;
|
|
const start = idx + marker.length;
|
|
let depth = 0;
|
|
for (let i = start; i < attrs.length; i++) {
|
|
if (attrs[i] === "(") {
|
|
depth++;
|
|
} else if (attrs[i] === ")") {
|
|
if (depth === 0) return attrs.slice(start, i);
|
|
depth--;
|
|
}
|
|
}
|
|
return attrs.slice(start);
|
|
}
|
|
|
|
// Excludes relation fields: anything carrying `@relation`, plus back-relation fields
|
|
// (e.g. `checkpoints Checkpoint[]`) identified by their type being another model name.
|
|
// `unparsed` collects any content line the field regex fails to match, so a future
|
|
// multi-line field can never silently vanish from the diff.
|
|
function parseScalarFields(
|
|
body: string,
|
|
modelNames: Set<string>
|
|
): { fields: ModelFields; unparsed: string[] } {
|
|
const fields: ModelFields = new Map();
|
|
const unparsed: string[] = [];
|
|
for (const rawLine of body.split("\n")) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith("@@")) continue;
|
|
const match = line.match(/^(\w+)\s+([A-Za-z_]\w*)(\[\])?(\?)?\s*(.*)$/);
|
|
if (!match) {
|
|
unparsed.push(line);
|
|
continue;
|
|
}
|
|
const [, name, type, arrayMark, optionalMark, rest] = match;
|
|
if (rest.includes("@relation")) continue;
|
|
if (modelNames.has(type)) continue;
|
|
fields.set(name, {
|
|
type,
|
|
array: arrayMark === "[]",
|
|
optional: optionalMark === "?",
|
|
default: extractDefault(rest),
|
|
});
|
|
}
|
|
return { fields, unparsed };
|
|
}
|
|
|
|
function parseSchema(schema: string) {
|
|
const stripped = stripComments(schema);
|
|
const modelBlocks = extractModelBlocks(stripped);
|
|
const modelNames = new Set(modelBlocks.keys());
|
|
const models = new Map<string, ModelFields>();
|
|
const unparsed: string[] = [];
|
|
for (const [name, body] of modelBlocks) {
|
|
const parsed = parseScalarFields(body, modelNames);
|
|
models.set(name, parsed.fields);
|
|
for (const line of parsed.unparsed) {
|
|
unparsed.push(`${name}: ${line}`);
|
|
}
|
|
}
|
|
return { models, modelNames, unparsed };
|
|
}
|
|
|
|
function describeField(field: FieldInfo | undefined): string {
|
|
if (!field) return "<missing>";
|
|
const array = field.array ? "[]" : "";
|
|
const optional = field.optional ? "?" : "";
|
|
const withDefault = field.default !== null ? ` @default(${field.default})` : "";
|
|
return `${field.type}${array}${optional}${withDefault}`;
|
|
}
|
|
|
|
const controlPlane = parseSchema(readSchema(CONTROL_PLANE_SCHEMA_PATH));
|
|
const dedicated = parseSchema(readSchema(DEDICATED_SCHEMA_PATH));
|
|
|
|
describe("dedicated run-ops schema parity", () => {
|
|
it("includes all 14 run-subgraph models in both schemas", () => {
|
|
for (const model of RUN_SUBGRAPH_MODELS) {
|
|
expect(Array.from(dedicated.modelNames)).toContain(model);
|
|
expect(Array.from(controlPlane.modelNames)).toContain(model);
|
|
}
|
|
});
|
|
|
|
it("fails on any content line the field parser doesn't recognise (no silent skips)", () => {
|
|
// Scope the control-plane check to run-subgraph models so an unrelated control-plane schema
|
|
// edit can't break this run-ops test; the dedicated schema contains only run-ops models.
|
|
const inRunSubgraph = (entry: string) =>
|
|
RUN_SUBGRAPH_MODELS.some((model) => entry.startsWith(`${model}: `));
|
|
expect(controlPlane.unparsed.filter(inRunSubgraph)).toEqual([]);
|
|
expect(dedicated.unparsed).toEqual([]);
|
|
});
|
|
|
|
it("reproduces every scalar column of each run-subgraph model bidirectionally with matching type/nullability/array/default", () => {
|
|
const mismatches: string[] = [];
|
|
|
|
for (const modelName of RUN_SUBGRAPH_MODELS) {
|
|
const controlFields = controlPlane.models.get(modelName);
|
|
const dedicatedFields = dedicated.models.get(modelName);
|
|
if (!controlFields || !dedicatedFields) {
|
|
mismatches.push(`${modelName}: model not found in one of the two schemas`);
|
|
continue;
|
|
}
|
|
|
|
const fieldNames = new Set([...controlFields.keys(), ...dedicatedFields.keys()]);
|
|
for (const fieldName of fieldNames) {
|
|
const controlField = controlFields.get(fieldName);
|
|
const dedicatedField = dedicatedFields.get(fieldName);
|
|
|
|
if (!controlField) {
|
|
mismatches.push(
|
|
`${modelName}.${fieldName}: dedicated has ${describeField(
|
|
dedicatedField
|
|
)} but the control-plane schema has no such scalar field`
|
|
);
|
|
continue;
|
|
}
|
|
if (!dedicatedField) {
|
|
mismatches.push(
|
|
`${modelName}.${fieldName}: control-plane has ${describeField(
|
|
controlField
|
|
)} but the dedicated schema has no such scalar field`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const matches =
|
|
dedicatedField.type === controlField.type &&
|
|
dedicatedField.array === controlField.array &&
|
|
dedicatedField.optional === controlField.optional &&
|
|
dedicatedField.default === controlField.default;
|
|
|
|
if (!matches) {
|
|
mismatches.push(
|
|
`${modelName}.${fieldName}: control-plane=${describeField(
|
|
controlField
|
|
)} dedicated=${describeField(dedicatedField)}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(mismatches).toEqual([]);
|
|
});
|
|
|
|
it("references no control-plane model as a relation target", () => {
|
|
const dedicatedText = stripComments(readSchema(DEDICATED_SCHEMA_PATH));
|
|
for (const model of CONTROL_PLANE_ONLY_MODELS) {
|
|
// A relation target appears as ` fieldName Model @relation(...)`. A bare
|
|
// scalar column like `projectId String` is fine; the model TYPE must be absent.
|
|
const relationTarget = new RegExp(
|
|
`@relation[^\\n]*\\b${model}\\b|\\b${model}\\b[^\\n]*@relation`
|
|
);
|
|
expect(dedicatedText).not.toMatch(relationTarget);
|
|
expect(dedicatedText).not.toMatch(new RegExp(`\\s${model}(\\?|\\[\\])?\\s`));
|
|
}
|
|
});
|
|
|
|
it("keeps the group-(A) waitpoint-block references FK-FREE (scalar columns / explicit FK-free join models)", () => {
|
|
const dedicatedText = stripComments(readSchema(DEDICATED_SCHEMA_PATH));
|
|
// TaskRunWaitpoint must NOT carry a `@relation` to Waitpoint/TaskRun/BatchTaskRun.
|
|
const trw = dedicatedText.match(/model TaskRunWaitpoint \{[\s\S]*?\n\}/)![0];
|
|
expect(trw).not.toMatch(/@relation/);
|
|
expect(trw).toMatch(/waitpointId\s+String/);
|
|
expect(trw).toMatch(/taskRunId\s+String/);
|
|
// The two implicit M2M sets are replaced by explicit FK-free join models.
|
|
expect(dedicatedText).toMatch(/model WaitpointRunConnection \{/);
|
|
expect(dedicatedText).toMatch(/model CompletedWaitpoint \{/);
|
|
const wrc = dedicatedText.match(/model WaitpointRunConnection \{[\s\S]*?\n\}/)![0];
|
|
expect(wrc).not.toMatch(/@relation/);
|
|
// Waitpoint completion back-refs are scalar, not relations.
|
|
const wp = dedicatedText.match(/model Waitpoint \{[\s\S]*?\n\}/)![0];
|
|
expect(wp).not.toMatch(/completedByTaskRun\s+TaskRun\s*\?\s*@relation/);
|
|
});
|
|
|
|
it("keeps the group-(B) co-resident references as real FKs (e.g. TaskRunAttempt.taskRun)", () => {
|
|
const dedicatedText = stripComments(readSchema(DEDICATED_SCHEMA_PATH));
|
|
const attempt = dedicatedText.match(/model TaskRunAttempt \{[\s\S]*?\n\}/)![0];
|
|
// The attempt->run relation stays a real FK (always co-resident).
|
|
expect(attempt).toMatch(/taskRun\s+TaskRun\s+@relation/);
|
|
});
|
|
});
|