diff --git a/apps/web/features/editor/publish-dialog.test.ts b/apps/web/features/editor/publish-dialog.test.ts index 29176c53..d280756c 100644 --- a/apps/web/features/editor/publish-dialog.test.ts +++ b/apps/web/features/editor/publish-dialog.test.ts @@ -33,4 +33,26 @@ describe("publish error messages", () => { "The draft changed while publishing. Review the latest changes and try again.", ); }); + + test("prefers Convex error data over the client error envelope", () => { + const error = new ConvexError( + "[CONVEX M(releases:publish)] [Request ID: abc] Server Error\n Called by client", + ); + error.data = + "The draft changed while publishing. Review the latest changes and try again."; + + expect(getPublishErrorMessage(error)).toBe( + "The draft changed while publishing. Review the latest changes and try again.", + ); + }); + + test("uses the fallback for an empty Convex error envelope", () => { + expect( + getPublishErrorMessage( + new Error( + "[CONVEX M(releases:publish)] [Request ID: abc] Server Error\n Called by client", + ), + ), + ).toBe("The site could not publish"); + }); }); diff --git a/apps/web/features/editor/publish-dialog.tsx b/apps/web/features/editor/publish-dialog.tsx index 0db37a95..d4f3ad63 100644 --- a/apps/web/features/editor/publish-dialog.tsx +++ b/apps/web/features/editor/publish-dialog.tsx @@ -31,11 +31,14 @@ export function getPublishErrorMessage( error: unknown, fallback = "The site could not publish", ): string { + const dataMessage = getPublishErrorDataMessage(error); + if (dataMessage) return dataMessage; + if (error instanceof Error && error.message.trim()) { - return cleanPublishErrorMessage(error.message); + return cleanPublishErrorMessage(error.message) || fallback; } if (typeof error === "string" && error.trim()) { - return cleanPublishErrorMessage(error); + return cleanPublishErrorMessage(error) || fallback; } if ( error && @@ -44,17 +47,50 @@ export function getPublishErrorMessage( typeof error.message === "string" && error.message.trim() ) { - return cleanPublishErrorMessage(error.message); + return cleanPublishErrorMessage(error.message) || fallback; } return fallback; } +function getPublishErrorDataMessage(error: unknown): string | null { + if (!error || typeof error !== "object" || !("data" in error)) { + return null; + } + + const data = error.data; + if (typeof data === "string" && data.trim()) { + return cleanPublishErrorMessage(data) || null; + } + if ( + data && + typeof data === "object" && + "message" in data && + typeof data.message === "string" && + data.message.trim() + ) { + return cleanPublishErrorMessage(data.message) || null; + } + return null; +} + function cleanPublishErrorMessage(message: string): string { + const trimmedMessage = message.trim(); const convexErrorMarker = "Uncaught ConvexError:"; - const markerIndex = message.indexOf(convexErrorMarker); - if (markerIndex === -1) return message; + const markerIndex = trimmedMessage.indexOf(convexErrorMarker); + if (markerIndex === -1) { + if ( + /^\[CONVEX [^\]]+\([^)]*\)\](?: \[Request ID: [^\]]+\])? Server Error(?:\s+Called by client)?$/s.test( + trimmedMessage, + ) + ) { + return ""; + } + return trimmedMessage; + } - const errorMessage = message.slice(markerIndex + convexErrorMarker.length); + const errorMessage = trimmedMessage.slice( + markerIndex + convexErrorMarker.length, + ); const [userMessage = errorMessage] = errorMessage.split( /\s+at\s+[^\s(]+\s*\(/, 1, diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 9354db65..e25e8772 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -66,6 +66,7 @@ import type * as pageGuests from "../pageGuests.js"; import type * as pages from "../pages.js"; import type * as permissions from "../permissions.js"; import type * as publication from "../publication.js"; +import type * as publicationCleanup from "../publicationCleanup.js"; import type * as publicationMigrations from "../publicationMigrations.js"; import type * as published from "../published.js"; import type * as releasePublication from "../releasePublication.js"; @@ -157,6 +158,7 @@ declare const fullApi: ApiFromModules<{ pages: typeof pages; permissions: typeof permissions; publication: typeof publication; + publicationCleanup: typeof publicationCleanup; publicationMigrations: typeof publicationMigrations; published: typeof published; releasePublication: typeof releasePublication; diff --git a/packages/backend/convex/publicationCleanup.ts b/packages/backend/convex/publicationCleanup.ts new file mode 100644 index 00000000..28da9688 --- /dev/null +++ b/packages/backend/convex/publicationCleanup.ts @@ -0,0 +1,216 @@ +import { + cleanup as cleanupWorkflow, + getStatus, + type WorkflowId, + type WorkflowStatus, +} from "@convex-dev/workflow"; +import { v } from "convex/values"; +import { components, internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import type { MutationCtx } from "./_generated/server"; +import { internalMutation } from "./_generated/server"; + +const CLEANUP_BATCH_SIZE = 25; + +const cleanupPhase = v.union( + v.literal("releases"), + v.literal("pages"), + v.literal("search"), + v.literal("migrationRuns"), +); + +type CleanupPhase = "releases" | "pages" | "search" | "migrationRuns"; + +const nextPhase: Record = { + releases: "pages", + pages: "search", + search: "migrationRuns", + migrationRuns: undefined, +}; + +async function scheduleNext( + ctx: Pick, + args: { phase: CleanupPhase; cursor?: string }, +) { + await ctx.scheduler.runAfter(0, internal.publicationCleanup.run, args); +} + +async function assertReleaseHasNoReferences( + ctx: MutationCtx, + releaseId: Id<"siteReleases">, + siteId: Id<"sites">, +): Promise { + const siteReference = await ctx.db + .query("sites") + .filter((q) => + q.or( + q.eq(q.field("liveReleaseId"), releaseId), + q.eq(q.field("draftBaseReleaseId"), releaseId), + ), + ) + .first(); + const successor = await ctx.db + .query("siteReleases") + .filter((q) => q.eq(q.field("previousReleaseId"), releaseId)) + .first(); + const [page, library, folder, file, change] = await Promise.all([ + ctx.db + .query("releasePages") + .withIndex("by_release", (q) => q.eq("releaseId", releaseId)) + .first(), + ctx.db + .query("releaseLibraries") + .withIndex("by_release", (q) => q.eq("releaseId", releaseId)) + .first(), + ctx.db + .query("releaseFolders") + .withIndex("by_release", (q) => q.eq("releaseId", releaseId)) + .first(), + ctx.db + .query("releaseFiles") + .withIndex("by_release", (q) => q.eq("releaseId", releaseId)) + .first(), + ctx.db + .query("releaseChanges") + .withIndex("by_release", (q) => q.eq("releaseId", releaseId)) + .first(), + ]); + const event = (await ctx.db + .query("publicationEvents") + .withIndex("by_site", (q) => q.eq("siteId", siteId)) + .filter((q) => + q.or( + q.eq(q.field("fromReleaseId"), releaseId), + q.eq(q.field("toReleaseId"), releaseId), + ), + ) + .first()) as unknown; + + if ( + siteReference || + successor || + page || + library || + folder || + file || + change || + event + ) { + throw new Error(`Failed release ${releaseId} still has references`); + } +} + +async function cleanupWorkflowStorage( + ctx: MutationCtx, + workflowId: string, +): Promise { + const typedWorkflowId = workflowId as WorkflowId; + let status: WorkflowStatus; + try { + status = await getStatus(ctx, components.workflow, typedWorkflowId); + } catch (error) { + if (error instanceof Error && error.message.includes("not found")) { + return; + } + throw error; + } + if (status.type === "inProgress") { + throw new Error( + `Legacy publication workflow ${workflowId} is still active`, + ); + } + await cleanupWorkflow(ctx, components.workflow, typedWorkflowId); +} + +export const run = internalMutation({ + args: { + phase: cleanupPhase, + cursor: v.optional(v.string()), + }, + returns: v.null(), + handler: async (ctx, { phase, cursor }) => { + if (phase === "releases") { + const page = await ctx.db + .query("siteReleases") + .paginate({ cursor: cursor ?? null, numItems: CLEANUP_BATCH_SIZE }); + for (const release of page.page) { + if (release.publicationStatus === "failed") { + await assertReleaseHasNoReferences(ctx, release._id, release.siteId); + if (release.publicationWorkflowId) { + await cleanupWorkflowStorage(ctx, release.publicationWorkflowId); + } + await ctx.db.delete(release._id); + continue; + } + if ( + release.publicationStatus !== undefined || + release.publicationFailure !== undefined || + release.publicationWorkflowId !== undefined || + release.publicationUpdatedAt !== undefined + ) { + if (release.publicationWorkflowId) { + await cleanupWorkflowStorage(ctx, release.publicationWorkflowId); + } + await ctx.db.patch(release._id, { + publicationStatus: undefined, + publicationFailure: undefined, + publicationWorkflowId: undefined, + publicationUpdatedAt: undefined, + }); + } + } + if (!page.isDone) { + await scheduleNext(ctx, { phase, cursor: page.continueCursor }); + } else { + await scheduleNext(ctx, { phase: nextPhase[phase]! }); + } + return null; + } + + if (phase === "pages") { + const page = await ctx.db + .query("releasePages") + .paginate({ cursor: cursor ?? null, numItems: CLEANUP_BATCH_SIZE }); + for (const releasePage of page.page) { + if (releasePage.descriptionText !== undefined) { + await ctx.db.patch(releasePage._id, { descriptionText: undefined }); + } + } + if (!page.isDone) { + await scheduleNext(ctx, { phase, cursor: page.continueCursor }); + } else { + await scheduleNext(ctx, { phase: nextPhase[phase]! }); + } + return null; + } + + if (phase === "search") { + const page = await ctx.db + .query("searchEntries") + .paginate({ cursor: cursor ?? null, numItems: CLEANUP_BATCH_SIZE }); + for (const entry of page.page) { + if ( + entry.scopeId.startsWith("release:") || + entry.scopeId.startsWith("site:") + ) { + await ctx.db.delete(entry._id); + } + } + if (!page.isDone) { + await scheduleNext(ctx, { phase, cursor: page.continueCursor }); + } else { + await scheduleNext(ctx, { phase: nextPhase[phase]! }); + } + return null; + } + + const page = await ctx.db + .query("publicationMigrationRuns") + .paginate({ cursor: cursor ?? null, numItems: CLEANUP_BATCH_SIZE }); + for (const run of page.page) await ctx.db.delete(run._id); + if (!page.isDone) { + await scheduleNext(ctx, { phase, cursor: page.continueCursor }); + } + return null; + }, +});