167 lines
4.9 KiB
TypeScript
167 lines
4.9 KiB
TypeScript
import { createHash } from "crypto";
|
|
import { prisma } from "~/db.server";
|
|
import { BaseService, ServiceValidationError } from "./baseService.server";
|
|
import { normalizePromptOverrideSource } from "./promptOverrideSource";
|
|
|
|
export class PromptService extends BaseService {
|
|
async promoteVersion(promptId: string, versionId: string, options?: { sourceGuard?: boolean }) {
|
|
const target = await this._prisma.promptVersion.findUnique({
|
|
where: { id: versionId },
|
|
});
|
|
|
|
if (!target) {
|
|
throw new ServiceValidationError("Version not found", 404);
|
|
}
|
|
|
|
if (target.promptId !== promptId) {
|
|
throw new ServiceValidationError("Version does not belong to this prompt", 400);
|
|
}
|
|
|
|
if (options?.sourceGuard && target.source !== "code") {
|
|
throw new ServiceValidationError(
|
|
"Only code-sourced versions can be promoted. Use the override API instead.",
|
|
400
|
|
);
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.$executeRaw`
|
|
UPDATE "prompt_versions"
|
|
SET "labels" = array_remove("labels", 'current')
|
|
WHERE "promptId" = ${promptId} AND 'current' = ANY("labels")
|
|
`;
|
|
await tx.$executeRaw`
|
|
UPDATE "prompt_versions"
|
|
SET "labels" = array_append("labels", 'current')
|
|
WHERE "id" = ${versionId} AND NOT ('current' = ANY("labels"))
|
|
`;
|
|
});
|
|
}
|
|
|
|
async createOverride(
|
|
promptId: string,
|
|
data: {
|
|
textContent: string;
|
|
model?: string;
|
|
commitMessage?: string;
|
|
source?: string;
|
|
createdBy?: string;
|
|
}
|
|
) {
|
|
const contentHash = createHash("sha256").update(data.textContent).digest("hex").slice(0, 16);
|
|
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
const latest = await tx.promptVersion.findFirst({
|
|
where: { promptId },
|
|
orderBy: { version: "desc" },
|
|
});
|
|
const nextVersion = (latest?.version ?? 0) + 1;
|
|
|
|
await tx.$executeRaw`
|
|
UPDATE "prompt_versions"
|
|
SET "labels" = array_remove("labels", 'override')
|
|
WHERE "promptId" = ${promptId} AND 'override' = ANY("labels")
|
|
`;
|
|
|
|
// Defence in depth: reject the reserved `code` source regardless of
|
|
// caller, in case a future caller skips the route-layer check.
|
|
const safeSource = normalizePromptOverrideSource(data.source);
|
|
|
|
await tx.promptVersion.create({
|
|
data: {
|
|
promptId,
|
|
version: nextVersion,
|
|
textContent: data.textContent,
|
|
model: data.model || null,
|
|
source: safeSource,
|
|
commitMessage: data.commitMessage || null,
|
|
contentHash,
|
|
labels: ["override"],
|
|
createdBy: data.createdBy,
|
|
},
|
|
});
|
|
|
|
return { version: nextVersion };
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
async updateOverride(
|
|
promptId: string,
|
|
data: {
|
|
textContent?: string;
|
|
model?: string;
|
|
commitMessage?: string;
|
|
}
|
|
) {
|
|
const overrideVer = await this._prisma.promptVersion.findFirst({
|
|
where: { promptId, labels: { has: "override" } },
|
|
orderBy: { version: "desc" },
|
|
});
|
|
|
|
if (!overrideVer) {
|
|
throw new ServiceValidationError("No active override to update", 400);
|
|
}
|
|
|
|
const contentString = data.textContent ?? overrideVer.textContent ?? "";
|
|
const contentHash = createHash("sha256").update(contentString).digest("hex").slice(0, 16);
|
|
|
|
await this._prisma.promptVersion.update({
|
|
where: { id: overrideVer.id },
|
|
data: {
|
|
textContent: contentString,
|
|
model: data.model ?? overrideVer.model,
|
|
commitMessage: data.commitMessage ?? overrideVer.commitMessage,
|
|
contentHash,
|
|
},
|
|
});
|
|
}
|
|
|
|
async removeOverride(promptId: string) {
|
|
await this.#removeLabel(promptId, "override");
|
|
}
|
|
|
|
async reactivateOverride(promptId: string, versionId: string) {
|
|
const target = await this._prisma.promptVersion.findUnique({
|
|
where: { id: versionId },
|
|
});
|
|
|
|
if (!target) {
|
|
throw new ServiceValidationError("Version not found", 404);
|
|
}
|
|
|
|
if (target.promptId !== promptId) {
|
|
throw new ServiceValidationError("Version does not belong to this prompt", 400);
|
|
}
|
|
|
|
if (target.source === "code") {
|
|
throw new ServiceValidationError(
|
|
"Code-sourced versions cannot be reactivated as overrides",
|
|
400
|
|
);
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.$executeRaw`
|
|
UPDATE "prompt_versions"
|
|
SET "labels" = array_remove("labels", 'override')
|
|
WHERE "promptId" = ${promptId} AND 'override' = ANY("labels")
|
|
`;
|
|
await tx.$executeRaw`
|
|
UPDATE "prompt_versions"
|
|
SET "labels" = array_append("labels", 'override')
|
|
WHERE "id" = ${versionId} AND NOT ('override' = ANY("labels"))
|
|
`;
|
|
});
|
|
}
|
|
|
|
async #removeLabel(promptId: string, label: string) {
|
|
await this._prisma.$executeRaw`
|
|
UPDATE "prompt_versions"
|
|
SET "labels" = array_remove("labels", ${label})
|
|
WHERE "promptId" = ${promptId} AND ${label} = ANY("labels")
|
|
`;
|
|
}
|
|
}
|