fix(news): persist publication requests and retry cache delivery
CI / check (push) Successful in 3m12s
CI / deploy (push) Successful in 1m13s
CI / publish-container (push) Successful in 42s

This commit is contained in:
Simo committed 2026-09-13 18:33:45 +02:00
1 parent c977fe95ba
commit 52f6d1491f
40 files changed
+733 -134

No files matched your search

+105
View File
@@ -2,6 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
rows: [] as unknown[][],
operations: new Map<
string,
{ id: string; requestHash: string; resultJson: string | null }
>(),
effects: [] as unknown[],
insert: vi.fn(),
update: vi.fn(),
invalidate: vi.fn(),
@@ -32,7 +37,32 @@ vi.mock("next/navigation", () => ({
},
}));
vi.mock("@/lib/db", async () => {
const { MySqlDialect } = await import("drizzle-orm/mysql-core");
const dialect = new MySqlDialect();
const database = {
execute: async (statement: import("drizzle-orm").SQL) => {
const { sql, params } = dialect.sqlToQuery(statement);
if (sql.startsWith("INSERT INTO cms_operations")) {
const [id, actor, kind, key, hash] = params;
const identity = `${actor}:${kind}:${key}`;
if (!state.operations.has(identity))
state.operations.set(identity, {
id: String(id),
requestHash: String(hash),
resultJson: null,
});
} else if (sql.startsWith("SELECT id,request_hash")) {
return [[state.operations.get(params.join(":"))]];
} else if (sql.startsWith("UPDATE cms_operations")) {
const row = [...state.operations.values()].find(
(row) => row.id === params[1],
);
if (row) row.resultJson = String(params[0]);
} else if (sql.startsWith("INSERT INTO cms_outbox"))
state.effects.push(params);
else throw new Error(`Unexpected SQL: ${sql}`);
return [[]];
},
select: () => ({
from: () => ({
where: () => ({
@@ -62,6 +92,7 @@ import { createArticle, updateArticle } from "./admin-articles";
function form(extra: Record<string, string> = {}) {
const f = new FormData();
for (const [k, v] of Object.entries({
requestKey: "11111111-1111-4111-8111-111111111111",
title: "Test news",
slug: "test-news",
shortStory: "Summary",
@@ -76,8 +107,30 @@ describe("news saves", () => {
beforeEach(() => {
vi.clearAllMocks();
state.rows = [];
state.operations.clear();
state.effects = [];
state.invalidate.mockReset();
state.insert.mockResolvedValue([{ insertId: 1 }]);
});
it("replays a committed creation without another article or notification", async () => {
const first = await createArticle(form());
const retry = await createArticle(form());
expect(first.ok).toBe(true);
expect(retry).toEqual(first);
expect(state.insert).toHaveBeenCalledOnce();
expect(state.notify).toHaveBeenCalledOnce();
expect(state.effects).toHaveLength(1);
});
it("returns success when cache refresh fails after the article commits", async () => {
state.invalidate.mockRejectedValue(Error("cache unavailable"));
await expect(createArticle(form())).resolves.toMatchObject({ ok: true });
expect(state.effects).toHaveLength(1);
});
it("rejects reuse of a committed request key with different content", async () => {
expect((await createArticle(form())).ok).toBe(true);
expect((await createArticle(form({ title: "Different" }))).ok).toBe(false);
expect(state.insert).toHaveBeenCalledOnce();
});
it("preserves a recoverable error and logs its reference", async () => {
state.insert.mockRejectedValueOnce(Error("Unknown column status"));
const result = await createArticle(form());
@@ -132,6 +185,58 @@ describe("news saves", () => {
);
expect(state.invalidate).toHaveBeenCalledOnce();
});
it("replays a committed update without stale-token failure or another revision", async () => {
const existing = {
title: "Old",
slug: "test-news",
image: "",
shortStory: "",
fullStory: "",
status: "draft",
publishAt: null,
publishedAt: null,
};
state.rows = [[existing]];
const data = form({ id: "1", baseToken: articleEditToken(existing) });
const first = await updateArticle(data);
expect(first.ok).toBe(true);
expect(await updateArticle(data)).toEqual(first);
expect(state.update).toHaveBeenCalledOnce();
expect(state.insert).toHaveBeenCalledOnce();
expect(state.notify).toHaveBeenCalledOnce();
expect(state.effects).toHaveLength(1);
});
it("accepts long article bodies while detecting body changes on replay", async () => {
const body = "a".repeat(100_000);
expect((await createArticle(form({ fullStory: body }))).ok).toBe(true);
expect((await createArticle(form({ fullStory: `${body}b` }))).ok).toBe(
false,
);
expect(state.insert).toHaveBeenCalledOnce();
});
it("preserves the scheduled instant without publishing early", async () => {
expect(
(
await createArticle(
form({ status: "scheduled", publishAt: "2099-01-01T00:00:00Z" }),
)
).ok,
).toBe(true);
expect(state.insert).toHaveBeenCalledWith(
expect.objectContaining({
status: "scheduled",
publishAt: new Date("2099-01-01T00:00:00Z"),
publishedAt: null,
}),
);
expect(state.notify).not.toHaveBeenCalled();
});
it("does not report a committed article as failed when notification dispatch throws", async () => {
state.notify.mockImplementationOnce(() => {
throw Error("notification failure");
});
await expect(createArticle(form())).resolves.toMatchObject({ ok: true });
});
it("rejects an outdated editor without inserting a revision or overwriting the article", async () => {
state.rows = [
[
+136 -80
View File
@@ -1,11 +1,13 @@
"use server";
import { createHash, randomUUID } from "node:crypto";
import { and, eq, ne } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { enqueueEffect, runOperation } from "@/features/operations/server";
import { requirePermission } from "@/lib/admin/guard";
import { articleEditToken } from "@/lib/article-edit-token";
import {
@@ -77,37 +79,77 @@ async function refreshNews() {
revalidatePath("/me");
revalidatePath("/");
}
function operationInput(
form: FormData,
input: ReturnType<typeof readArticleInput>,
) {
return {
...input,
fullStory: createHash("sha256").update(input.fullStory).digest("hex"),
publishAt: input.publishAt?.toISOString() ?? null,
id: String(form.get("id") ?? ""),
baseToken: String(form.get("baseToken") ?? ""),
};
}
async function refreshSavedNews(id?: bigint) {
try {
await refreshNews();
if (id) revalidatePath(`/admin/articles/${id}`);
} catch (error) {
logger.error("News saved; cache refresh will be retried", {
module: "news",
error,
});
}
}
export async function createArticle(
formData: FormData,
): Promise<ArticleSaveResult> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
let input: ReturnType<typeof readArticleInput>;
let slug: string;
let notification: Parameters<typeof notify>[0] | undefined;
try {
input = readArticleInput(formData);
const { rawSlug, ...fields } = input;
slug = await uniqueSlug(rawSlug || fields.title);
const now = new Date();
await db.insert(WebsiteArticles).values({
...fields,
slug,
userId: staff.id,
createdAt: now,
updatedAt: now,
publishedAt: fields.status === "published" ? now : null,
});
const input = readArticleInput(formData);
await runOperation(
{
actorId: staff.id,
kind: "news.create",
key: String(formData.get("requestKey") ?? randomUUID()),
input: operationInput(formData, input),
},
async (tx, operationId) => {
const { rawSlug, ...fields } = input;
const slug = await uniqueSlug(rawSlug || fields.title, undefined, tx);
const now = new Date();
await tx.insert(WebsiteArticles).values({
...fields,
slug,
userId: staff.id,
createdAt: now,
updatedAt: now,
publishedAt: fields.status === "published" ? now : null,
});
await enqueueEffect(tx, operationId, "news.refresh");
if (input.status === "published")
notification = {
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
};
return { ok: true };
},
);
} catch (error) {
return saveFailure(error, "create");
}
// Auxiliary services must not turn a committed insert into an apparent failure.
if (input.status === "published")
notify({
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
});
await refreshNews();
if (notification) {
try {
notify(notification);
} catch (error) {
logger.error("News notification failed", { module: "news", error });
}
}
await refreshSavedNews();
return { ok: true, data: { redirectTo: "/admin/articles" } };
}
export async function updateArticle(
@@ -122,66 +164,80 @@ export async function updateArticle(
let slug = "";
try {
input = readArticleInput(formData);
await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id))
.limit(1)
.for("update");
if (!existing) throw new ArticleInputError("articleNotFound");
if (formData.get("baseToken") !== articleEditToken(existing))
throw new ArticleInputError("editConflict");
const historyId = String(id);
const historyBefore = await historySnapshot(tx, "news", historyId);
await tx.insert(ArticleRevisions).values({
articleId: id,
userId: staff.id,
payload: JSON.stringify({
title: existing.title,
slug: existing.slug,
image: existing.image,
shortStory: existing.shortStory,
fullStory: existing.fullStory,
status: existing.status,
publishAt: existing.publishAt?.toISOString() ?? "",
baseToken: "",
}),
});
const { rawSlug, ...fields } = input;
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
slug =
requestedSlug === existing.slug
? existing.slug
: await uniqueSlug(requestedSlug, id, tx);
becamePublished =
fields.status === "published" && existing.status !== "published";
await tx
.update(WebsiteArticles)
.set({
...fields,
slug,
publishedAt:
fields.status === "published"
? (existing.publishedAt ?? new Date())
: null,
updatedAt: new Date(),
})
.where(eq(WebsiteArticles.id, id));
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
});
await runOperation(
{
actorId: staff.id,
kind: "news.update",
key: String(formData.get("requestKey") ?? randomUUID()),
input: operationInput(formData, input),
},
async (tx, operationId) => {
const [existing] = await tx
.select()
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id))
.limit(1)
.for("update");
if (!existing) throw new ArticleInputError("articleNotFound");
if (formData.get("baseToken") !== articleEditToken(existing))
throw new ArticleInputError("editConflict");
const historyId = String(id);
const historyBefore = await historySnapshot(tx, "news", historyId);
await tx.insert(ArticleRevisions).values({
articleId: id,
userId: staff.id,
payload: JSON.stringify({
title: existing.title,
slug: existing.slug,
image: existing.image,
shortStory: existing.shortStory,
fullStory: existing.fullStory,
status: existing.status,
publishAt: existing.publishAt?.toISOString() ?? "",
baseToken: "",
}),
});
const { rawSlug, ...fields } = input;
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
slug =
requestedSlug === existing.slug
? existing.slug
: await uniqueSlug(requestedSlug, id, tx);
becamePublished =
fields.status === "published" && existing.status !== "published";
await tx
.update(WebsiteArticles)
.set({
...fields,
slug,
publishedAt:
fields.status === "published"
? (existing.publishedAt ?? new Date())
: null,
updatedAt: new Date(),
})
.where(eq(WebsiteArticles.id, id));
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
await enqueueEffect(tx, operationId, "news.refresh");
return { ok: true };
},
);
} catch (error) {
return saveFailure(error, "update");
}
if (becamePublished)
notify({
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
});
await refreshNews();
revalidatePath(`/admin/articles/${id}`);
if (becamePublished) {
try {
notify({
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
});
} catch (error) {
logger.error("News notification failed", { module: "news", error });
}
}
await refreshSavedNews(id);
return { ok: true, data: { redirectTo: "/admin/articles" } };
}