diff --git a/apps/web/src/components/sections-editor/content-protocol-api.ts b/apps/web/src/components/sections-editor/content-protocol-api.ts new file mode 100644 index 0000000000..895896654c --- /dev/null +++ b/apps/web/src/components/sections-editor/content-protocol-api.ts @@ -0,0 +1,330 @@ +/** + * The editor's reads and writes over the content protocol. The data hooks + * (`useDecofile`, `useLiveMeta`, the write hooks) call these when the project + * has a protocol backend, and keep their legacy paths otherwise. + * + * Polling follows the protocol: one batched request reads the blocks and the + * schema, each conditional on the revision (or schema version) last seen, and + * gets a short "not modified" when nothing changed. After a write the client + * adopts the returned revision, so its next poll is "not modified" unless + * someone else wrote. + */ + +import { type QueryClient, useQuery } from "@tanstack/react-query"; +import { ServeLostError } from "./serve-save-error"; +import { + ContentProtocolError, + type BlocksListResult, + type SchemaGetResult, +} from "@decocms/blocks/protocol"; +import { KEYS } from "@/lib/query-keys"; +import { sandboxGitStatusQueryKey } from "../thread/repository/sandbox-git-api"; +import { + blockKeysOfWrite, + mergePolledBlocks, + type ProtocolBackend, +} from "./content-backend"; +import { + type DecofilePatchBody, + type DecofileScopeParams, + decofileWriteMutationKey, +} from "./decofile-api"; +import type { LiveMeta } from "./resolve-schema"; +import { isSchemaAbsent, noSchemaMeta } from "./schemaless"; + +/** + * The read error while a protocol backend can't be used; the 502 keeps the + * hooks' retry rules and the blocks tab's "unreachable" state. + */ +export function protocolUnavailableError(what: string): Error { + const error = new Error(`${what} unavailable (content server unreachable)`); + (error as { status?: number }).status = 502; + return error; +} + +/** What a project's last read or write saw. */ +export interface ContentRevisions { + /** The blocks revision (the branch head on GitHub). */ + revision?: string; + schemaVersion?: string; +} + +interface ProtocolParams extends DecofileScopeParams { + threadId?: string | null; +} + +/** The cache entry of a protocol project's schema (a `useLiveMeta` key). */ +export function protocolMetaQueryKey( + params: DecofileScopeParams, + backend: ProtocolBackend, +) { + return KEYS.liveMeta( + params.orgSlug, + params.virtualMcpId, + params.branch, + `protocol${backend.cacheKeySuffix}`, + "", + ); +} + +/** The block names the project's in-flight writes touch. */ +function savingBlockKeys( + queryClient: QueryClient, + params: DecofileScopeParams, +): Set { + const pending = queryClient.getMutationCache().findAll({ + mutationKey: decofileWriteMutationKey( + params.orgSlug, + params.virtualMcpId, + params.branch, + ), + status: "pending", + }); + return new Set(pending.flatMap((m) => blockKeysOfWrite(m.state.variables))); +} + +/** + * Runs a request against the backend. When a `deco serve` can't be reached + * (it stopped or is restarting), its probe is reset, so the editor waits for + * it and reconnects on its own. + */ +async function guarded( + queryClient: QueryClient, + backend: ProtocolBackend, + params: DecofileScopeParams, + request: () => Promise, +): Promise { + try { + return await request(); + } catch (error) { + const lostServer = !(error instanceof ContentProtocolError); + if (backend.source === "local" && lostServer) { + void queryClient.resetQueries({ + queryKey: KEYS.contentBackend( + params.orgSlug, + params.virtualMcpId, + "local", + ), + }); + throw new ServeLostError(error); + } + throw error; + } +} + +export interface ProtocolContent { + blocks: Record; + /** The schema (`noSchemaMeta()` while there is none), or the error reading it. */ + meta: LiveMeta | ContentProtocolError; +} + +const inFlight = new Map>(); + +/** + * One poll: `blocks.list` and `schema.get` in one request. Updates both the + * decofile and the schema cache entries and resolves with both, so the two + * hooks share one request (concurrent calls for one project join it). + */ +export function pollProtocolContent( + queryClient: QueryClient, + backend: ProtocolBackend, + params: DecofileScopeParams, + cacheKey: string, +): Promise { + const running = inFlight.get(cacheKey); + if (running) return running; + const poll = readContent(queryClient, backend, params, cacheKey, true); + inFlight.set(cacheKey, poll); + return poll.finally(() => inFlight.delete(cacheKey)); +} + +async function readContent( + queryClient: QueryClient, + backend: ProtocolBackend, + params: DecofileScopeParams, + cacheKey: string, + conditional: boolean, +): Promise { + const blocksKey = KEYS.decofile(cacheKey); + const metaKey = protocolMetaQueryKey(params, backend); + const revisionsKey = KEYS.contentRevision(cacheKey); + const currentBlocks = + queryClient.getQueryData>(blocksKey); + const currentMeta = queryClient.getQueryData(metaKey); + const seen = + queryClient.getQueryData(revisionsKey) ?? undefined; + const blocksIfNoneMatch = + conditional && currentBlocks ? seen?.revision : undefined; + const schemaIfNoneMatch = + conditional && currentMeta ? seen?.schemaVersion : undefined; + + const [listed, schema] = await guarded(queryClient, backend, params, () => + backend.client.batch([ + { + method: "blocks.list", + params: blocksIfNoneMatch ? { ifNoneMatch: blocksIfNoneMatch } : {}, + }, + { + method: "schema.get", + params: schemaIfNoneMatch ? { ifNoneMatch: schemaIfNoneMatch } : {}, + }, + ]), + ); + if (!listed?.ok) throw listed?.error ?? new Error("no blocks.list"); + if (!schema) throw new Error("no schema.get"); + const list = listed.result as BlocksListResult; + // No schema yet: an empty, marked schema, so the blocks still open (as + // plain fields) and the next poll asks again unconditionally. + const absent = isSchemaAbsent(schema); + const read = schema.ok && !absent ? (schema.result as SchemaGetResult) : null; + // A version outlived the entry it described: read both whole. + if ( + (list.notModified && !currentBlocks) || + (read?.notModified && !currentMeta) + ) { + return readContent(queryClient, backend, params, cacheKey, false); + } + + const blocks = list.notModified + ? currentBlocks! + : mergePolledBlocks( + list.blocks, + currentBlocks, + savingBlockKeys(queryClient, params), + ); + const meta: LiveMeta | ContentProtocolError = absent + ? noSchemaMeta() + : !read + ? (schema as { error: ContentProtocolError }).error + : read.notModified + ? currentMeta! + : (read.schema as LiveMeta); + queryClient.setQueryData(revisionsKey, { + revision: list.notModified ? seen?.revision : list.revision, + schemaVersion: !read + ? undefined + : read.notModified + ? seen?.schemaVersion + : (read.version ?? undefined), + }); + queryClient.setQueryData(blocksKey, blocks); + if (!(meta instanceof ContentProtocolError)) { + queryClient.setQueryData(metaKey, meta); + } + return { blocks, meta }; +} + +/** + * One `blocks.apply`; adopts the returned revision. On GitHub the save + * rewrote the project's CDN draft, so the publish status and the draft + * pointer (`useProtocolDraft`, whose version is the draft's ETag) are + * refreshed. In a sandbox the save is a working-tree change, so the header's + * status is refreshed. + * `ifMatch` guards entries by version (`null`: only if it doesn't exist yet); + * a failed guard rejects the whole patch with a Conflict. + */ +export async function applyProtocolPatch( + queryClient: QueryClient, + backend: ProtocolBackend, + params: ProtocolParams, + cacheKey: string, + patch: DecofilePatchBody, + ifMatch?: Record, +): Promise<{ revision: string }> { + const result = await guarded(queryClient, backend, params, () => + backend.client.blocksApply({ + set: patch.set as Record> | undefined, + delete: patch.delete, + ...(ifMatch ? { ifMatch } : {}), + }), + ); + queryClient.setQueryData( + KEYS.contentRevision(cacheKey), + (seen) => ({ ...seen, revision: result.revision }), + ); + if (backend.source === "github") { + void queryClient.invalidateQueries({ + queryKey: KEYS.protocolDraftRead( + params.orgSlug, + params.virtualMcpId, + params.branch, + ), + }); + await queryClient.invalidateQueries({ + queryKey: sandboxGitStatusQueryKey({ + orgSlug: params.orgSlug, + virtualMcpId: params.virtualMcpId, + branch: params.branch, + threadId: params.threadId ?? null, + }), + }); + } + if (backend.source === "sandbox") { + // The save changed the working tree: the header's changes count moves. + void queryClient.invalidateQueries({ + queryKey: sandboxGitStatusQueryKey({ + orgSlug: params.orgSlug, + virtualMcpId: params.virtualMcpId, + branch: params.branch, + threadId: params.threadId ?? null, + }), + }); + } + return { revision: result.revision }; +} + +/** What the session read answers for a hosted v8 project. */ +interface HostedDraftPointer { + /** `delivery.decocms.com/sites//drafts/.json`; null without a draft. */ + draft: string | null; + /** The draft object's ETag, so each save makes a new pointer. */ + version: string | null; +} + +/** + * The `?__draft=` pointer of a project on the GitHub backend: its draft on + * the delivery CDN, `@`, which the site's SDK revalidates on + * every render. Read from the session's decofile GET, on load and again + * after each save (see `applyProtocolPatch`). `params` is `null` for any + * other backend; the pointer is null while there is no draft. + */ +export function useProtocolDraft( + params: DecofileScopeParams | null, +): string | null { + const { data } = useQuery({ + queryKey: KEYS.protocolDraftRead( + params?.orgSlug ?? "", + params?.virtualMcpId ?? "", + params?.branch ?? "", + ), + queryFn: async (): Promise => { + const res = await fetch( + `/api/${params!.orgSlug}/decofile/${encodeURIComponent(params!.virtualMcpId)}/${encodeURIComponent(params!.branch)}`, + { cache: "no-store" }, + ); + if (!res.ok) throw new Error(`draft pointer read failed: ${res.status}`); + return (await res.json()) as HostedDraftPointer; + }, + enabled: !!params, + staleTime: Number.POSITIVE_INFINITY, + }); + if (!params || !data?.draft || !data.version) return null; + return `${data.draft}@${data.version}`; +} + +/** + * The content revision the last read or write of `cacheKey` saw, or + * `undefined` before the first one. A save moves it, and so does a read that + * finds the content changed elsewhere. + */ +export function useContentRevision(cacheKey: string): string | undefined { + const { data: seen } = useQuery({ + queryKey: KEYS.contentRevision(cacheKey), + enabled: false, + queryFn: async () => { + throw new Error("content revisions are set by reads and writes"); + }, + staleTime: Number.POSITIVE_INFINITY, + }); + return seen?.revision; +} diff --git a/apps/web/src/components/sections-editor/content-protocol-schemaless.test.ts b/apps/web/src/components/sections-editor/content-protocol-schemaless.test.ts new file mode 100644 index 0000000000..6f3a032c78 --- /dev/null +++ b/apps/web/src/components/sections-editor/content-protocol-schemaless.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; +import { + type BatchCall, + type BatchOutcome, + type ContentClient, + ContentProtocolError, + type DescribeResult, + ErrorCode, +} from "@decocms/blocks/protocol"; +import type { ProtocolBackend } from "./content-backend"; +import { + pollProtocolContent, + protocolMetaQueryKey, +} from "./content-protocol-api"; +import type { LiveMeta } from "./resolve-schema"; +import { isNoSchemaMeta } from "./schemaless"; + +const params = { + orgSlug: "", + virtualMcpId: "deco-serve", + branch: "working-tree", +}; +const blocks = { + hero: { __resolveType: "site/sections/Hero.tsx", title: "Hi" }, +}; +const schema = { + manifest: { blocks: {} }, + schema: { definitions: {}, root: {} }, +}; + +/** A `deco serve` whose schema the test swaps; it records each batch. */ +function fakeServer(initial: "none" | "not-found" | "present") { + const state = { + schema: initial as "none" | "not-found" | "present", + calls: [] as BatchCall[][], + }; + const schemaOutcome = (call: BatchCall): BatchOutcome => { + if (state.schema === "not-found") { + return { + ok: false, + error: new ContentProtocolError(ErrorCode.NotFound, "no schema"), + }; + } + if (state.schema === "none") { + return { + ok: true, + result: { + notModified: false, + version: null, + resolvedRef: null, + schema: null, + }, + }; + } + const ifNoneMatch = (call.params as { ifNoneMatch?: string } | undefined) + ?.ifNoneMatch; + return { + ok: true, + result: + ifNoneMatch === "s1" + ? { notModified: true, version: "s1" } + : { notModified: false, version: "s1", resolvedRef: null, schema }, + }; + }; + const client = { + batch: async (calls: BatchCall[]) => { + state.calls.push(calls); + return calls.map( + (call): BatchOutcome => + call.method === "schema.get" + ? schemaOutcome(call) + : { + ok: true, + result: { + notModified: false, + revision: "r1", + resolvedRef: null, + blocks, + versions: { hero: "v1" }, + diagnostics: [], + }, + }, + ); + }, + } as unknown as ContentClient; + const backend: ProtocolBackend = { + kind: "protocol", + source: "local", + client, + describe: { pollIntervalMs: 2000 } as DescribeResult, + cacheKeySuffix: ":serve:test", + hasSchema: initial === "present", + }; + return { state, backend }; +} + +describe("pollProtocolContent without a schema", () => { + for (const initial of ["none", "not-found"] as const) { + it(`lists the blocks with an empty, marked schema (${initial}), then picks the schema up`, async () => { + const queryClient = new QueryClient(); + const { state, backend } = fakeServer(initial); + + const first = await pollProtocolContent( + queryClient, + backend, + params, + "k", + ); + expect(first.blocks).toEqual(blocks); + expect(isNoSchemaMeta(first.meta as LiveMeta)).toBe(true); + const cached = queryClient.getQueryData( + protocolMetaQueryKey(params, backend), + ); + expect(isNoSchemaMeta(cached)).toBe(true); + + // The next poll asks for the schema unconditionally: there's no version yet. + await pollProtocolContent(queryClient, backend, params, "k"); + const second = state.calls[1]!.find((c) => c.method === "schema.get"); + expect(second?.params).toEqual({}); + + // `deco schema` ran: the next poll swaps the real schema in. + state.schema = "present"; + const third = await pollProtocolContent( + queryClient, + backend, + params, + "k", + ); + expect(third.meta).toEqual(schema); + expect(isNoSchemaMeta(third.meta as LiveMeta)).toBe(false); + expect( + isNoSchemaMeta( + queryClient.getQueryData( + protocolMetaQueryKey(params, backend), + ), + ), + ).toBe(false); + }); + } +}); diff --git a/apps/web/src/components/sections-editor/serve-save-error.test.ts b/apps/web/src/components/sections-editor/serve-save-error.test.ts new file mode 100644 index 0000000000..e619cddf8f --- /dev/null +++ b/apps/web/src/components/sections-editor/serve-save-error.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { ContentProtocolError, ErrorCode } from "@decocms/blocks/protocol"; +import { toast } from "sonner"; +import { en } from "@/i18n/en/index.ts"; +import { interpolate } from "@/i18n/interpolate.ts"; +import type { TFunction } from "@/i18n/use-t.ts"; +import { + ServeLostError, + saveErrorDetail, + saveErrorMessage, +} from "./serve-save-error"; +import { SITE_EDITOR_SAVE_TOAST, toastSaveError } from "./site-editor-toast"; + +const t: TFunction = (key, vars) => interpolate(en[key], vars); + +const conflict = () => + new ContentProtocolError(ErrorCode.Conflict, "a precondition failed"); + +describe("save messages for business users (hosted, sandbox, legacy)", () => { + for (const source of ["github", "sandbox", null] as const) { + test(`${source ?? "legacy"}: plain words, never the server or git`, () => { + const cases: [Error, RegExp][] = [ + [conflict(), /someone else changed this/], + [ + new ContentProtocolError(ErrorCode.ReadOnly, "read-only"), + /can't be edited/, + ], + [ + new ContentProtocolError(ErrorCode.LimitExceeded, "too big"), + /too large/, + ], + [new Error("HTTP 502 from api.github.com"), /^Couldn't save/], + [new ServeLostError(new TypeError("Failed to fetch")), /^Couldn't/], + ]; + for (const [error, message] of cases) { + const text = saveErrorMessage(t, error, source); + expect(text).toMatch(message); + for (const jargon of [/deco serve/i, /\bgit\b/i, /computer/i]) { + expect(text).not.toMatch(jargon); + } + // The error's own text is never the headline: it goes to Details. + expect(text).not.toContain(error.message); + expect(saveErrorDetail(error, source)).toContain(error.message); + } + }); + } + + test("a refused block says to fix the fields; Details names them", () => { + const error = new ContentProtocolError(ErrorCode.InvalidBlock, "invalid", { + violations: [ + { name: "Header", rule: "required", message: "title is required" }, + ], + }); + expect(saveErrorMessage(t, error, "github")).toBe( + "Not saved: some fields aren't filled in correctly. Fix them and try again.", + ); + expect(saveErrorDetail(error, "github")).toContain("title is required"); + }); +}); + +describe("save messages for a local deco serve (a developer)", () => { + test("keep naming the server and the error's own text", () => { + expect(saveErrorMessage(t, conflict(), "local")).toMatch( + /changed on your computer/, + ); + expect(saveErrorMessage(t, new Error("disk full"), "local")).toBe( + "Save failed: disk full", + ); + expect(saveErrorDetail(new Error("disk full"), "local")).toBeNull(); + }); +}); + +describe("one toast per save failure", () => { + afterEach(() => { + toast.dismiss(); + }); + + test("a burst of failing autosaves shows a single toast", () => { + for (let i = 0; i < 3; i++) { + toastSaveError(t, new Error(`HTTP 50${i}`), "github"); + } + const shown = toast + .getToasts() + .filter((item) => item.id === SITE_EDITOR_SAVE_TOAST); + expect(shown).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/sections-editor/serve-save-error.ts b/apps/web/src/components/sections-editor/serve-save-error.ts new file mode 100644 index 0000000000..24cbe6c7a7 --- /dev/null +++ b/apps/web/src/components/sections-editor/serve-save-error.ts @@ -0,0 +1,107 @@ +/** + * Why a save failed, in words for whoever is editing. A local `deco serve` + * (`source: "local"`) is a developer's own machine: its copy names the + * server, the disk and git, and keeps the error's own text. Everywhere else + * (Studio's hosted backend, a sandbox, the legacy paths) the editor is a + * business user: plain words and a next step, with the error's own text kept + * for a "Details" disclosure ({@link saveErrorDetail}), never the headline. + */ + +import { + type BlockViolation, + ContentProtocolError, + ErrorCode, +} from "@decocms/blocks/protocol"; +import type { TFunction } from "@/i18n/use-t.ts"; +import type { ContentSource } from "./content-backend"; + +/** A local `deco serve` request that got no answer (see `guarded`). */ +export class ServeLostError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + this.name = "ServeLostError"; + } +} + +/** Whether a failed save was refused for an outdated `ifMatch`. */ +export function isSaveConflict(error: unknown): boolean { + return ( + error instanceof ContentProtocolError && error.code === ErrorCode.Conflict + ); +} + +function violationsDetail(error: ContentProtocolError): string { + const violations = (error.data as { violations?: BlockViolation[] } | null) + ?.violations; + if (!Array.isArray(violations) || violations.length === 0) { + return error.message; + } + return violations + .slice(0, 3) + .map((violation) => violation.message) + .join("; "); +} + +export function saveErrorMessage( + t: TFunction, + error: Error, + source: ContentSource | null, +): string { + if (source === "local") return localSaveErrorMessage(t, error); + if (error instanceof ContentProtocolError) { + switch (error.code) { + case ErrorCode.Conflict: + return t("siteEditor.save.conflict"); + case ErrorCode.ReadOnly: + return t("siteEditor.save.readOnly"); + case ErrorCode.InvalidBlock: + case ErrorCode.InvalidParams: + return t("siteEditor.save.invalid"); + case ErrorCode.LimitExceeded: + return t("siteEditor.save.tooLarge"); + } + } + return t("siteEditor.save.failed"); +} + +/** + * The error's own text, for the "Details" disclosure under a business-user + * message; null when the message already says it all (a local `deco serve`, + * which keeps the text in its message, or an error with no text). + */ +export function saveErrorDetail( + error: Error, + source: ContentSource | null, +): string | null { + if (source === "local") return null; + if (error instanceof ContentProtocolError) { + // A refused block: which fields to fix, in the server's own words. + const message = + error.code === ErrorCode.InvalidBlock || + error.code === ErrorCode.InvalidParams + ? violationsDetail(error) + : error.message; + return message ? `${error.code}: ${message}` : null; + } + return error.message || null; +} + +function localSaveErrorMessage(t: TFunction, error: Error): string { + if (error instanceof ServeLostError) return t("decoServe.save.serverGone"); + if (error instanceof ContentProtocolError) { + switch (error.code) { + case ErrorCode.Conflict: + return t("decoServe.save.conflict"); + case ErrorCode.ReadOnly: + return t("decoServe.save.readOnly"); + case ErrorCode.InvalidBlock: + case ErrorCode.InvalidParams: + return t("decoServe.save.invalid", { detail: violationsDetail(error) }); + case ErrorCode.LimitExceeded: + return t("decoServe.save.tooLarge"); + } + } + return t("sectionsEditor.sectionsEditor.saveFailed", { + error: error.message, + }); +} diff --git a/apps/web/src/components/sections-editor/site-editor-toast.tsx b/apps/web/src/components/sections-editor/site-editor-toast.tsx new file mode 100644 index 0000000000..5233c03b09 --- /dev/null +++ b/apps/web/src/components/sections-editor/site-editor-toast.tsx @@ -0,0 +1,48 @@ +/** + * The site editor's toasts: one per user action. Each kind of action reuses + * one sonner id, so a retry, a burst of failing autosaves or a follow-up + * outcome replaces the toast on screen instead of stacking another one. + * Developer detail (an error's own text, a status code) sits behind a + * "Details" disclosure, never in the headline. + */ + +import { toast } from "sonner"; +import type { TFunction } from "@/i18n/use-t.ts"; +import type { ContentSource } from "./content-backend"; +import { saveErrorDetail, saveErrorMessage } from "./serve-save-error"; + +/** Every save failure in the site editor shares this toast. */ +export const SITE_EDITOR_SAVE_TOAST = "site-editor-save"; + +/** A collapsed "Details" line holding the developer detail of a failure. */ +function ErrorDetails({ label, detail }: { label: string; detail: string }) { + return ( +
+ + {label} + +

+ {detail} +

+
+ ); +} + +/** A toast `description` for `detail`, or nothing when there is none. */ +function errorDetailsDescription(t: TFunction, detail: string | null) { + return detail ? ( + + ) : undefined; +} + +/** A failed save, as one toast (replacing an earlier save failure's). */ +export function toastSaveError( + t: TFunction, + error: Error, + source: ContentSource | null, +) { + toast.error(saveErrorMessage(t, error, source), { + id: SITE_EDITOR_SAVE_TOAST, + description: errorDetailsDescription(t, saveErrorDetail(error, source)), + }); +} diff --git a/apps/web/src/components/sections-editor/use-decofile.ts b/apps/web/src/components/sections-editor/use-decofile.ts index cba854011d..77692957fb 100644 --- a/apps/web/src/components/sections-editor/use-decofile.ts +++ b/apps/web/src/components/sections-editor/use-decofile.ts @@ -10,6 +10,11 @@ import { buildDecofileFetchUrl } from "./preview-fetch-url"; import { readCommittedJson } from "./read-committed-file"; import { decofileErrorStatus } from "./decofile-read-status"; import { usePackagePath } from "./use-package-path"; +import { useContentBackend } from "./use-content-backend"; +import { + pollProtocolContent, + protocolUnavailableError, +} from "./content-protocol-api"; interface UseDecofileParams { orgSlug: string; @@ -27,18 +32,40 @@ interface UseDecofileParams { * looks. Same string ⇒ shared cache; a Local edit never bleeds into the real * branch's decofile and vice-versa. */ -export function decofileCacheKey(input: { +function decofileCacheKey(input: { orgSlug: string; virtualMcpId: string; branch: string; localPreviewUrl?: string | null; + /** A content-protocol backend's suffix (a `deco serve` endpoint), if any. */ + protocolSuffix?: string | null; }): string { const base = `${input.orgSlug}/${input.virtualMcpId}/${input.branch}`; + if (input.protocolSuffix) return `${base}${input.protocolSuffix}`; return input.localPreviewUrl ? `${base}:local:${input.localPreviewUrl}` : base; } +/** + * The decofile cache key for a project, given its content backend. Pass + * `tunnel: false` for writers that never target the Local tunnel's entry. + */ +export function useDecofileCacheKey( + params: { orgSlug: string; virtualMcpId: string; branch: string } | null, + options?: { tunnel?: boolean }, +): string { + const { url: localPreviewUrl } = useLocalPreviewUrl(params?.virtualMcpId); + const backend = useContentBackend(params?.virtualMcpId, params?.branch); + if (!params) return ""; + return backend.kind === "protocol" + ? decofileCacheKey({ ...params, protocolSuffix: backend.cacheKeySuffix }) + : decofileCacheKey({ + ...params, + localPreviewUrl: options?.tunnel === false ? null : localPreviewUrl, + }); +} + export function useDecofile( params: UseDecofileParams | null, options?: { fetchEnabled?: boolean }, @@ -51,7 +78,10 @@ export function useDecofile( */ const { url: localPreviewUrl } = useLocalPreviewUrl(params?.virtualMcpId); const localOverride = !!localPreviewUrl; - const key = params ? decofileCacheKey({ ...params, localPreviewUrl }) : ""; + // Next-major Blocks sites: the content protocol, polled (see content-backend). + const backend = useContentBackend(params?.virtualMcpId, params?.branch); + const protocol = backend.kind === "protocol" ? backend : null; + const key = useDecofileCacheKey(params); // `fetchEnabled` means the dev server is up, so the live `/.decofile` route is // worth hitting. When it's down we read `.deco/blocks.gen.json` straight from // the working tree — and if that artifact is absent (it's commonly gitignored) @@ -82,6 +112,18 @@ export function useDecofile( return useQuery({ queryKey: KEYS.decofile(key), queryFn: async () => { + if (protocol) { + const content = await pollProtocolContent( + queryClient, + protocol, + params!, + key, + ); + return content.blocks; + } + if (backend.kind === "unavailable") { + throw protocolUnavailableError("decofile"); + } if (localOverride) { const res = await fetch( new URL("/.decofile", localPreviewUrl).toString(), @@ -142,8 +184,11 @@ export function useDecofile( }); throw err; }, - enabled: !!params, - staleTime: 30_000, + enabled: !!params && backend.kind !== "pending", + staleTime: protocol ? 0 : 30_000, + refetchInterval: protocol?.describe.pollIntervalMs, + refetchIntervalInBackground: false, + refetchOnWindowFocus: protocol ? "always" : undefined, // 502 = preview unreachable / nothing available yet. Retrying just hammers // a known-down endpoint; sandbox-events-context re-invalidates this query // on first daemon contact (and again when the dev server reports @@ -158,7 +203,9 @@ export function useDecofile( // 404 = no decofile route on this repo (not a deco site) — as terminal as // 502 for retry purposes. retry: (failureCount, error) => { - if (fastPreviewActive || localOverride) return failureCount < 3; + if (protocol || fastPreviewActive || localOverride) { + return failureCount < 3; + } const status = (error as { status?: number }).status; return status !== 502 && status !== 404 && failureCount < 2; }, diff --git a/apps/web/src/components/sections-editor/use-delete-block.ts b/apps/web/src/components/sections-editor/use-delete-block.ts index 6451b2292a..b25a1f89ad 100644 --- a/apps/web/src/components/sections-editor/use-delete-block.ts +++ b/apps/web/src/components/sections-editor/use-delete-block.ts @@ -1,7 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url"; -import { decofileCacheKey } from "./use-decofile"; import { usePackagePath } from "./use-package-path"; import { KEYS } from "@/lib/query-keys"; import { decoBlockFilePath } from "./deco-block-key"; @@ -15,6 +14,9 @@ import { } from "./decofile-api"; import { sandboxGitStatusQueryKey } from "../thread/repository/sandbox-git-api"; import { useOptionalChatTask } from "@/components/chat/chat-context"; +import { useContentBackend } from "./use-content-backend"; +import { applyProtocolPatch } from "./content-protocol-api"; +import { useDecofileCacheKey } from "./use-decofile"; import { buildSandboxUrl } from "@/sdk/sandbox-url"; interface UseDeleteBlockParams { @@ -47,17 +49,24 @@ export function useDeleteBlock({ * reading it without the tunnel patched a decofile nobody is rendering. */ const { url: localPreviewUrl } = useLocalPreviewUrl(virtualMcpId); - const cacheKey = decofileCacheKey({ - orgSlug, - virtualMcpId, - branch, - localPreviewUrl, - }); + const backend = useContentBackend(virtualMcpId, branch); + const protocol = backend.kind === "protocol" ? backend : null; + const cacheKey = useDecofileCacheKey({ orgSlug, virtualMcpId, branch }); return useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), scope: decofileWriteScope(orgSlug, virtualMcpId, branch), mutationFn: async ({ blockKey }: { blockKey: string }) => { + if (protocol) { + await applyProtocolPatch( + queryClient, + protocol, + { orgSlug, virtualMcpId, branch, threadId }, + cacheKey, + { delete: [blockKey] }, + ); + return { ok: true as const, existed: true }; + } // Local: no persistence — the optimistic cache removal is the delete. if (localPreviewUrl) return { ok: true as const, existed: true }; if (fastPreviewActive) { diff --git a/apps/web/src/components/sections-editor/use-live-meta.ts b/apps/web/src/components/sections-editor/use-live-meta.ts index 27ca22f723..3d269fa4af 100644 --- a/apps/web/src/components/sections-editor/use-live-meta.ts +++ b/apps/web/src/components/sections-editor/use-live-meta.ts @@ -1,4 +1,4 @@ -import { type Query, useQuery } from "@tanstack/react-query"; +import { type Query, useQuery, useQueryClient } from "@tanstack/react-query"; import { exponentialBackoffWithJitter } from "@decocms/shared/std"; import { KEYS } from "@/lib/query-keys"; import { decoRepoPath } from "./deco-repo-path"; @@ -10,6 +10,13 @@ import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url"; import { usePackagePath } from "./use-package-path"; import type { LiveMeta } from "./resolve-schema"; +import { useContentBackend } from "./use-content-backend"; +import { useDecofileCacheKey } from "./use-decofile"; +import { + pollProtocolContent, + protocolMetaQueryKey, + protocolUnavailableError, +} from "./content-protocol-api"; interface UseLiveMetaParams { orgSlug: string; @@ -114,11 +121,34 @@ export function useLiveMeta( params?.virtualMcpId, ); const fastPreviewActive = !localOverride && runtime === "cms"; + // Next-major Blocks sites read the committed schema over the content + // protocol: never from the running site. + const backend = useContentBackend(params?.virtualMcpId, params?.branch); + const protocol = backend.kind === "protocol" ? backend : null; + const queryClient = useQueryClient(); + const decofileKey = useDecofileCacheKey(params ?? null); + const queryKey = params + ? protocol + ? protocolMetaQueryKey(params, protocol) + : liveMetaQueryKey({ ...params, previewUrl, productionUrl }) + : KEYS.liveMeta(""); return useQuery({ - queryKey: params - ? liveMetaQueryKey({ ...params, previewUrl, productionUrl }) - : KEYS.liveMeta(""), + queryKey, queryFn: async () => { + if (protocol) { + // The same batched poll as the decofile's: one request for both. + const { meta } = await pollProtocolContent( + queryClient, + protocol, + params!, + decofileKey, + ); + if (meta instanceof Error) throw meta; + return meta; + } + if (backend.kind === "unavailable") { + throw protocolUnavailableError("live meta"); + } const fetchMeta = async (baseUrl: string): Promise => { const url = new URL("/live/_meta", baseUrl).href; const res = await fetch(url, { cache: "no-store" }).catch(() => null); @@ -172,10 +202,12 @@ export function useLiveMeta( (err as { status?: number }).status = 502; throw err; }, - enabled: !!params, - refetchInterval: options?.refetchInterval, + enabled: !!params && backend.kind !== "pending", + // On the protocol, the decofile's poll refreshes the schema too. + refetchInterval: protocol ? false : options?.refetchInterval, refetchIntervalInBackground: false, - staleTime: 300_000, + refetchOnWindowFocus: protocol ? false : undefined, + staleTime: protocol ? protocol.describe.pollIntervalMs : 300_000, // 502 = nothing reachable yet. The sandbox lifecycle re-invalidates this // query when the dev server comes up (see sandbox-events-context), so // retrying just hammers a known-down endpoint. Sandbox-less Fast Preview @@ -184,7 +216,7 @@ export function useLiveMeta( // retries ARE the recovery there. // Local mode, like sandbox-less Fast Preview, has no lifecycle event to re-invalidate a 502, so bounded retries are its recovery. retry: (failureCount, error) => - fastPreviewActive || localOverride + protocol || fastPreviewActive || localOverride ? failureCount < 3 : (error as { status?: number }).status !== 502 && failureCount < 3, retryDelay: (attempt) => diff --git a/apps/web/src/components/sections-editor/use-move-blocks.ts b/apps/web/src/components/sections-editor/use-move-blocks.ts index 56aead1c8e..36d8bc23de 100644 --- a/apps/web/src/components/sections-editor/use-move-blocks.ts +++ b/apps/web/src/components/sections-editor/use-move-blocks.ts @@ -1,7 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url"; -import { decofileCacheKey } from "./use-decofile"; import { usePackagePath } from "./use-package-path"; import { KEYS } from "@/lib/query-keys"; import { sanitizeSecretsForPersistence } from "@decocms/shared/decofile"; @@ -16,6 +15,9 @@ import { } from "./decofile-api"; import { sandboxGitStatusQueryKey } from "../thread/repository/sandbox-git-api"; import { useOptionalChatTask } from "@/components/chat/chat-context"; +import { useContentBackend } from "./use-content-backend"; +import { applyProtocolPatch } from "./content-protocol-api"; +import { useDecofileCacheKey } from "./use-decofile"; import { buildSandboxUrl } from "@/sdk/sandbox-url"; interface UseMoveBlocksParams { @@ -63,15 +65,26 @@ export function useMoveBlocks({ * nothing reads AND tried a real write against the branch. */ const { url: localPreviewUrl } = useLocalPreviewUrl(virtualMcpId); - const queryKey = KEYS.decofile( - decofileCacheKey({ orgSlug, virtualMcpId, branch, localPreviewUrl }), - ); + const backend = useContentBackend(virtualMcpId, branch); + const protocol = backend.kind === "protocol" ? backend : null; + const cacheKey = useDecofileCacheKey({ orgSlug, virtualMcpId, branch }); + const queryKey = KEYS.decofile(cacheKey); const mutation = useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), scope: decofileWriteScope(orgSlug, virtualMcpId, branch), mutationFn: async ({ writes: rawWrites, deletes }: BlockMove) => { const writes = sanitizeSecretsForPersistence(rawWrites); + if (protocol) { + // One `blocks.apply`: the write and the delete land together. + return applyProtocolPatch( + queryClient, + protocol, + { orgSlug, virtualMcpId, branch, threadId }, + cacheKey, + { set: writes, delete: deletes.filter((key) => !(key in writes)) }, + ); + } // Local: no persistence — the optimistic cache write is the save. if (localPreviewUrl) return { ok: true as const }; if (fastPreviewActive) { diff --git a/apps/web/src/components/sections-editor/use-save-block.ts b/apps/web/src/components/sections-editor/use-save-block.ts index 79d3b45708..c0185c6e5e 100644 --- a/apps/web/src/components/sections-editor/use-save-block.ts +++ b/apps/web/src/components/sections-editor/use-save-block.ts @@ -2,9 +2,10 @@ import { useEffect, useRef } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url"; -import { decofileCacheKey } from "./use-decofile"; +import { useDecofileCacheKey } from "./use-decofile"; +import { useContentBackend } from "./use-content-backend"; +import { applyProtocolPatch } from "./content-protocol-api"; import { usePackagePath } from "./use-package-path"; -import { toast } from "sonner"; import { sanitizeSecretsForPersistence } from "@decocms/shared/decofile"; import { decoBlockFilePath } from "./deco-block-key"; import { decoRepoPath } from "./deco-repo-path"; @@ -20,6 +21,8 @@ import { useOptionalChatTask } from "@/components/chat/chat-context"; import { buildSandboxUrl } from "@/sdk/sandbox-url"; import { KEYS } from "@/lib/query-keys"; import { useT } from "@/i18n/use-t"; +import { isSaveConflict } from "./serve-save-error"; +import { toastSaveError } from "./site-editor-toast"; /** Debounce window for form-driven block autosaves (ms). */ export const AUTOSAVE_DELAY = 700; @@ -30,6 +33,20 @@ interface UseSaveBlockParams { branch: string; } +/** + * A `blocks.apply` version guard for one save, decided when the save runs + * (saves of a branch run one at a time), not when it's scheduled. `ifMatch` + * returns the guard, or undefined for none; `onApplied` runs as soon as the + * guarded write lands, before the next save of the branch starts. Only the + * content-protocol backend sends it; the others have no versions. + */ +export interface SaveGuard { + ifMatch: (blockKey: string) => Record | undefined; + onApplied?: () => void; + /** A save the guard refused (or any failed guarded save): re-read the content. */ + onRejected?: (error: unknown) => void; +} + export function useSaveBlock({ orgSlug, virtualMcpId, @@ -49,26 +66,45 @@ export function useSaveBlock({ * in-place render picks the merged decofile up and repaints the tunnel frame. */ const { url: localPreviewUrl } = useLocalPreviewUrl(virtualMcpId); - const cacheKey = decofileCacheKey({ - orgSlug, - virtualMcpId, - branch, - localPreviewUrl, - }); + // Next-major Blocks sites: one `blocks.apply` per save. + const backend = useContentBackend(virtualMcpId, branch); + const protocol = backend.kind === "protocol" ? backend : null; + const cacheKey = useDecofileCacheKey({ orgSlug, virtualMcpId, branch }); + const t = useT(); - return useMutation({ + const mutation = useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), // Serialize a branch's writes so overlapping autosaves can't land an older payload last, dropping a newer edit. scope: decofileWriteScope(orgSlug, virtualMcpId, branch), mutationFn: async ({ blockKey, data, + guard, }: { blockKey: string; data: unknown; + guard?: SaveGuard; }) => { // Fail closed before any write path: a raw secret must never be persisted. data = sanitizeSecretsForPersistence(data); + if (protocol) { + const ifMatch = guard?.ifMatch(blockKey); + try { + const result = await applyProtocolPatch( + queryClient, + protocol, + { orgSlug, virtualMcpId, branch, threadId }, + cacheKey, + { set: { [blockKey]: data } }, + ifMatch, + ); + if (ifMatch) guard?.onApplied?.(); + return result; + } catch (error) { + if (ifMatch) guard?.onRejected?.(error); + throw error; + } + } // Local: no persistence — the optimistic cache write is the save. if (localPreviewUrl) return { blockKey, data }; if (fastPreviewActive) { @@ -127,8 +163,12 @@ export function useSaveBlock({ return { queryKey, blockKey, hadKey, previousValue }; }, // Restore only this mutation's own key so a concurrent sibling save isn't clobbered. - onError: (_error, _variables, context) => { + onError: (error, _variables, context) => { if (!context) return; + // Refused because the content changed on disk: load what is there now. + if (isSaveConflict(error)) { + void queryClient.invalidateQueries({ queryKey: context.queryKey }); + } queryClient.setQueryData( context.queryKey, (current: Record | undefined) => { @@ -151,6 +191,13 @@ export function useSaveBlock({ ); }, }); + + return { + ...mutation, + /** One toast for a failed save, worded for this backend's editor. */ + reportError: (error: Error) => + toastSaveError(t, error, protocol?.source ?? null), + }; } /** @@ -175,10 +222,9 @@ type SaveData = */ export function useDebouncedSaveBlock( params: UseSaveBlockParams, - opts?: { onSaved?: () => void }, + opts?: { onSaved?: () => void; guard?: SaveGuard }, ) { const saveBlock = useSaveBlock(params); - const t = useT(); const pendingRef = useRef>(new Map()); const timersRef = useRef>>( new Map(), @@ -191,15 +237,10 @@ export function useDebouncedSaveBlock( pendingRef.current.delete(blockKey); if (!resolved) return; saveBlock.mutate( - { blockKey, data: resolved }, + { blockKey, data: resolved, guard: opts?.guard }, { onSuccess: () => opts?.onSaved?.(), - onError: (err) => - toast.error( - t("sectionsEditor.sectionsEditor.saveFailed", { - error: err.message, - }), - ), + onError: saveBlock.reportError, }, ); }; diff --git a/apps/web/src/i18n/en/index.ts b/apps/web/src/i18n/en/index.ts index 0b07309de2..fa23f95b84 100644 --- a/apps/web/src/i18n/en/index.ts +++ b/apps/web/src/i18n/en/index.ts @@ -35,6 +35,7 @@ import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; import { chooseEditor } from "./choose-editor.ts"; import { decoServe } from "./deco-serve.ts"; +import { siteEditor } from "./site-editor.ts"; import { chat } from "./chat.ts"; import { credits } from "./credits.ts"; import { automations } from "./automations.ts"; @@ -85,6 +86,7 @@ export const en = { ...collections, ...chooseEditor, ...decoServe, + ...siteEditor, ...chat, ...credits, ...automations, diff --git a/apps/web/src/i18n/en/site-editor.ts b/apps/web/src/i18n/en/site-editor.ts new file mode 100644 index 0000000000..9794583287 --- /dev/null +++ b/apps/web/src/i18n/en/site-editor.ts @@ -0,0 +1,17 @@ +/** + * Site editor copy for business users: saving and publishing a site. Plain + * words only — never CDN, git, branch, commit, merge, pointer, revision or + * sha (`site-editor-copy.test.ts` enforces it). "Published" means live. + * Developer detail (an error's own text) goes behind `siteEditor.details`. + */ +export const siteEditor = { + "siteEditor.details": "Details", + "siteEditor.save.failed": "Couldn't save your change. Try again.", + "siteEditor.save.conflict": + "Not saved: someone else changed this at the same time. We're loading their version, so make your change again.", + "siteEditor.save.readOnly": "Not saved: this version can't be edited.", + "siteEditor.save.invalid": + "Not saved: some fields aren't filled in correctly. Fix them and try again.", + "siteEditor.save.tooLarge": + "Not saved: this content is too large. Make it smaller and try again.", +}; diff --git a/apps/web/src/i18n/pt-br/index.ts b/apps/web/src/i18n/pt-br/index.ts index 52609d792f..920bf2bfbb 100644 --- a/apps/web/src/i18n/pt-br/index.ts +++ b/apps/web/src/i18n/pt-br/index.ts @@ -35,6 +35,7 @@ import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; import { chooseEditor } from "./choose-editor.ts"; import { decoServe } from "./deco-serve.ts"; +import { siteEditor } from "./site-editor.ts"; import { chat } from "./chat.ts"; import { credits } from "./credits.ts"; import { automations } from "./automations.ts"; @@ -83,6 +84,7 @@ export const ptBR = { ...collections, ...chooseEditor, ...decoServe, + ...siteEditor, ...chat, ...credits, ...automations, diff --git a/apps/web/src/i18n/pt-br/site-editor.ts b/apps/web/src/i18n/pt-br/site-editor.ts new file mode 100644 index 0000000000..ea0629746b --- /dev/null +++ b/apps/web/src/i18n/pt-br/site-editor.ts @@ -0,0 +1,15 @@ +import type { siteEditor as siteEditorEn } from "../en/site-editor.ts"; + +export const siteEditor = { + "siteEditor.details": "Detalhes", + "siteEditor.save.failed": + "Não foi possível salvar sua alteração. Tente de novo.", + "siteEditor.save.conflict": + "Não foi salvo: outra pessoa alterou isto ao mesmo tempo. Estamos carregando a versão dela, então refaça sua alteração.", + "siteEditor.save.readOnly": + "Não foi salvo: esta versão não pode ser editada.", + "siteEditor.save.invalid": + "Não foi salvo: alguns campos não estão preenchidos corretamente. Corrija e tente de novo.", + "siteEditor.save.tooLarge": + "Não foi salvo: este conteúdo é grande demais. Diminua o tamanho e tente de novo.", +} satisfies Record; diff --git a/apps/web/src/i18n/site-editor-copy.test.ts b/apps/web/src/i18n/site-editor-copy.test.ts new file mode 100644 index 0000000000..6d1cd0fca3 --- /dev/null +++ b/apps/web/src/i18n/site-editor-copy.test.ts @@ -0,0 +1,51 @@ +/** + * The site editor speaks to business users: its copy never names the + * machinery behind a save or a publish. "Published" means live. + */ + +import { describe, expect, test } from "bun:test"; +import { en } from "./en/index.ts"; +import { ptBR } from "./pt-br/index.ts"; + +/** Namespaces whose every string a business user reads in the site editor. */ +const SITE_EDITOR_PREFIXES = ["siteEditor."]; + +const BANNED: RegExp[] = [ + /\bCDN\b/i, + /\bmerge[ds]?\b/i, + /mescl/i, + /\brebase/i, + /\bcommit/i, + /\bgit\b/i, + /\bbranch/i, + /\bpointer\b/i, + /latest\.json/i, + /\brevision/i, + /\bsha\b/i, + /make current/i, + /tornar atual/i, +]; + +const siteEditorEntries = (dict: Record) => + Object.entries(dict).filter(([key]) => + SITE_EDITOR_PREFIXES.some((prefix) => key.startsWith(prefix)), + ); + +describe("site editor copy", () => { + for (const [name, dict] of [ + ["en", en], + ["pt-br", ptBR], + ] as const) { + test(`${name}: no developer jargon`, () => { + const entries = siteEditorEntries(dict as Record); + expect(entries.length).toBeGreaterThan(0); + for (const [key, value] of entries) { + for (const banned of BANNED) { + if (banned.test(value)) { + throw new Error(`${key} says ${banned}: "${value}"`); + } + } + } + }); + } +}); diff --git a/knip.jsonc b/knip.jsonc index 5e67d42b95..0af3ce85c7 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -82,9 +82,9 @@ "apps/web/src/components/sections-editor/deco-serve-connection.ts": [ "exports" ], - "apps/web/src/components/sections-editor/use-content-backend.ts": [ + "apps/web/src/hooks/use-deco-serve-connection.ts": ["exports"], + "apps/web/src/components/sections-editor/content-protocol-api.ts": [ "exports" - ], - "apps/web/src/hooks/use-deco-serve-connection.ts": ["exports"] + ] } }