From 5ddee1f37356cee8becde1d4b72da4c19bc059b9 Mon Sep 17 00:00:00 2001 From: naaiyy Date: Fri, 28 Aug 2026 20:21:54 +0200 Subject: [PATCH 1/6] Refactor publication around immutable releases --- README.md | 8 +- .../dashboard/use-site-navigation.test.ts | 16 + .../features/dashboard/use-site-navigation.ts | 10 +- apps/web/features/editor/editor.tsx | 31 +- .../features/editor/publish-dialog.test.ts | 24 + apps/web/features/editor/publish-dialog.tsx | 101 +-- .../content/docs/publishing/index.fr.mdx | 22 +- .../content/docs/publishing/index.mdx | 21 +- .../features/published-sites/read-model.ts | 39 +- docs/publishing-migration.md | 60 ++ packages/backend/convex/_generated/api.d.ts | 4 + packages/backend/convex/draftRestore.test.ts | 149 +++- packages/backend/convex/draftRestores.ts | 107 ++- packages/backend/convex/editorWorkspace.ts | 12 +- packages/backend/convex/files.ts | 17 +- packages/backend/convex/libraries.ts | 3 +- .../convex/model/contentObjects.test.ts | 42 + .../backend/convex/model/contentObjects.ts | 26 + packages/backend/convex/model/draftSummary.ts | 8 +- .../backend/convex/model/publishedRelease.ts | 3 +- .../convex/model/releaseChangeDetails.ts | 17 +- .../backend/convex/model/releaseOperations.ts | 25 +- .../backend/convex/model/releaseState.test.ts | 35 +- packages/backend/convex/model/releaseState.ts | 17 + packages/backend/convex/publication.test.ts | 757 ++++++++++++++++++ packages/backend/convex/publication.ts | 504 ++++++++++++ .../convex/publicationMigrations.test.ts | 296 +++++++ .../backend/convex/publicationMigrations.ts | 408 ++++++++++ packages/backend/convex/published.ts | 16 +- .../backend/convex/releasePublication.test.ts | 276 ++++--- packages/backend/convex/releasePublication.ts | 685 ++++------------ packages/backend/convex/releases.ts | 208 +++-- packages/backend/convex/schema.ts | 73 +- packages/backend/convex/search.test.ts | 21 + packages/backend/convex/search.ts | 69 +- packages/backend/convex/siteDomains.ts | 3 + 36 files changed, 3148 insertions(+), 965 deletions(-) create mode 100644 apps/web/features/dashboard/use-site-navigation.test.ts create mode 100644 apps/web/features/editor/publish-dialog.test.ts create mode 100644 docs/publishing-migration.md create mode 100644 packages/backend/convex/model/contentObjects.test.ts create mode 100644 packages/backend/convex/publication.test.ts create mode 100644 packages/backend/convex/publication.ts create mode 100644 packages/backend/convex/publicationMigrations.test.ts create mode 100644 packages/backend/convex/publicationMigrations.ts diff --git a/README.md b/README.md index ef1bb85b..f8926239 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ Teams create structured sites with a block editor, organize pages and document l ## Publishing model -A site can be published or unpublished. Publishing makes the current saved content available at its generated URL and any verified custom domain. Unpublishing removes public access without deleting the site. - -Published sites currently read the latest saved page content. Historical deployment snapshots and rollback are not implemented yet. +A site can be published or unpublished. Each publish creates an immutable version +from the saved draft and switches the live site to that version atomically. +Unpublishing removes public access without deleting the site. Version history can +make an earlier version live or restore it to the draft before republishing. ## Tech stack @@ -182,7 +183,6 @@ Document text extraction can be connected through `EXTRACTION_API_URL` and `EXTR Active areas of work include: - Continued editor polish and accessibility -- Historical publishing snapshots and rollback - Broader custom-block APIs - Templates and starter sites - Analytics and insights diff --git a/apps/web/features/dashboard/use-site-navigation.test.ts b/apps/web/features/dashboard/use-site-navigation.test.ts new file mode 100644 index 00000000..4878a175 --- /dev/null +++ b/apps/web/features/dashboard/use-site-navigation.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { getSitesWithReadablePages } from "./use-site-navigation"; + +describe("site navigation page queries", () => { + test("does not request pages while a historical restore owns the site", () => { + const readableSite = { _id: "site-readable" }; + const restoringSite = { + _id: "site-restoring", + activeDraftRestoreId: "restore-1", + }; + + expect(getSitesWithReadablePages([readableSite, restoringSite])).toEqual([ + readableSite, + ]); + }); +}); diff --git a/apps/web/features/dashboard/use-site-navigation.ts b/apps/web/features/dashboard/use-site-navigation.ts index 45292b64..26554a47 100644 --- a/apps/web/features/dashboard/use-site-navigation.ts +++ b/apps/web/features/dashboard/use-site-navigation.ts @@ -17,6 +17,12 @@ export type SiteNavigationItem = { pages: Array; }; +export function getSitesWithReadablePages< + T extends { activeDraftRestoreId?: unknown }, +>(sites: readonly T[]): T[] { + return sites.filter((site) => !site.activeDraftRestoreId); +} + /** * Builds the workspace navigation from the long-lived site and page queries. * Keeping the dynamic query set behind one hook gives callers a single loading @@ -30,7 +36,7 @@ export function useSiteNavigation( if (!sites) return {}; return Object.fromEntries( - sites.map((site) => [ + getSitesWithReadablePages(sites).map((site) => [ site._id, { query: api.pages.list, args: { siteId: site._id } }, ]), @@ -42,7 +48,7 @@ export function useSiteNavigation( const navigation: SiteNavigationItem[] = []; for (const site of sites) { - const pages = pageResults[site._id]; + const pages = site.activeDraftRestoreId ? [] : pageResults[site._id]; if (pages === undefined) return undefined; if (pages instanceof Error) throw pages; diff --git a/apps/web/features/editor/editor.tsx b/apps/web/features/editor/editor.tsx index cde0b176..6343d640 100644 --- a/apps/web/features/editor/editor.tsx +++ b/apps/web/features/editor/editor.tsx @@ -66,6 +66,7 @@ function SiteEditorScreen({ ); const searchParams = useSearchParams(); const requestedAction = searchParams.get("action"); + const selectedPageId = selectedPage?._id; const unpublishSite = useMutation(api.releases.unpublish); @@ -122,6 +123,17 @@ function SiteEditorScreen({ ); }, [isPreviewing, siteId]); + useEffect(() => { + if (!restore?._id) return; + setActiveDialog(null); + setAiChatOpen(false); + }, [restore?._id]); + + useEffect(() => { + if (!selectedPageId) return; + setSaveStatus("idle"); + }, [selectedPageId]); + if (status === "loading") { return ; } @@ -231,18 +243,17 @@ function SiteEditorScreen({ ) : null} + )} - - ); } diff --git a/apps/web/features/editor/publish-dialog.test.ts b/apps/web/features/editor/publish-dialog.test.ts new file mode 100644 index 00000000..3d7e3134 --- /dev/null +++ b/apps/web/features/editor/publish-dialog.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { ConvexError } from "convex/values"; +import { getPublishErrorMessage } from "./publish-dialog"; + +describe("publish error messages", () => { + test("preserves the stale-draft error from Convex", () => { + const error = new ConvexError( + "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("handles serialized client errors", () => { + expect(getPublishErrorMessage({ message: "Publication failed" })).toBe( + "Publication failed", + ); + expect(getPublishErrorMessage("Publication failed")).toBe( + "Publication failed", + ); + }); +}); diff --git a/apps/web/features/editor/publish-dialog.tsx b/apps/web/features/editor/publish-dialog.tsx index 17b31116..a15e4656 100644 --- a/apps/web/features/editor/publish-dialog.tsx +++ b/apps/web/features/editor/publish-dialog.tsx @@ -16,7 +16,7 @@ import { Spinner } from "@baseblocks/ui/spinner"; import { Globe02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useMutation, useQuery } from "convex/react"; -import { useEffect, useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; type DraftChange = { @@ -27,6 +27,24 @@ type DraftChange = { details: string[]; }; +export function getPublishErrorMessage( + error: unknown, + fallback = "The site could not publish", +): string { + if (error instanceof Error && error.message.trim()) return error.message; + if (typeof error === "string" && error.trim()) return error; + if ( + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" && + error.message.trim() + ) { + return error.message; + } + return fallback; +} + export type DraftSummary = { draftRevision: number; hasUnpublishedChanges: boolean; @@ -53,64 +71,30 @@ export function PublishDialog({ | DraftChange[] | null | undefined; - const [requesting, setRequesting] = useState(false); - const [pendingPublication, setPendingPublication] = useState<{ - releaseId: Id<"siteReleases">; - number: number; - } | null>(null); - const publicationStatus = useQuery( - api.releases.getPublicationStatus, - pendingPublication ? { releaseId: pendingPublication.releaseId } : "skip", - ); - const publicationFinished = - publicationStatus === null || - publicationStatus?.status === "complete" || - publicationStatus?.status === "failed"; - const publishing = - requesting || (pendingPublication !== null && !publicationFinished); - - useEffect(() => { - if (!pendingPublication || publicationStatus === undefined) return; - if (publicationStatus?.status === "complete") { - toast.success(`Version ${pendingPublication.number} is live`, { - id: `publication:${pendingPublication.releaseId}:complete`, - }); - } else if (publicationStatus?.status === "failed") { - toast.error( - publicationStatus.failure ?? "The site could not be published.", - { id: `publication:${pendingPublication.releaseId}:failed` }, - ); - } else if (publicationStatus === null) { - toast.error( - "The draft changed before publication completed. Review it and try again.", - { id: `publication:${pendingPublication.releaseId}:missing` }, - ); - } - }, [pendingPublication, publicationStatus]); + const [publishing, setPublishing] = useState(false); + const publishInFlight = useRef(false); const handlePublish = () => { - setRequesting(true); + if (publishInFlight.current) return; + publishInFlight.current = true; + setPublishing(true); + // Freeze the revision for this request only. If the request detects a + // concurrent edit, a later retry must use the newly rendered revision. + const expectedDraftRevision = draftSummary.draftRevision; void publish({ siteId, - expectedDraftRevision: draftSummary.draftRevision, + expectedDraftRevision, }).then( (result) => { - setRequesting(false); - if (result.reused) { - toast.success(`Version ${result.number} is live again`); - onOpenChange(false); - return; - } - setPendingPublication({ - releaseId: result.releaseId, - number: result.number, - }); + publishInFlight.current = false; + setPublishing(false); + toast.success(`Version ${result.number} is live`); + onOpenChange(false); }, (error: unknown) => { - setRequesting(false); - toast.error( - error instanceof Error ? error.message : "The site could not publish", - ); + publishInFlight.current = false; + setPublishing(false); + toast.error(getPublishErrorMessage(error)); }, ); }; @@ -121,15 +105,9 @@ export function PublishDialog({ }; return ( - + { - if (publicationStatus?.status === "complete") onOpenChange(false); - }} returnFocusTo={returnFocusTo} showCloseButton={false} > @@ -165,7 +143,7 @@ export function PublishDialog({