Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/atomic-expected-publish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes publication workflows so callers can pass the approved `_rev` to publish, unpublish, or discard a draft and receive a `CONFLICT` response when the entry changed. Calls that omit `_rev` keep the existing behavior.
46 changes: 39 additions & 7 deletions packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { isSqlite } from "../../database/dialect-helpers.js";
import { BylineRepository } from "../../database/repositories/byline.js";
import type { ContentBylineInput } from "../../database/repositories/byline.js";
import { CommentRepository } from "../../database/repositories/comment.js";
import { ContentRepository, isSystemOrderField } from "../../database/repositories/content.js";
import {
ContentRepository,
isSystemOrderField,
type ContentRevisionPrecondition,
} from "../../database/repositories/content.js";
import { RedirectRepository } from "../../database/repositories/redirect.js";
import { RevisionRepository } from "../../database/repositories/revision.js";
import { SeoRepository } from "../../database/repositories/seo.js";
Expand Down Expand Up @@ -39,7 +43,7 @@ import { invalidateRedirectCache } from "../../redirects/cache.js";
import { FTSManager } from "../../search/fts-manager.js";
import { invalidateTermCache } from "../../taxonomies/index.js";
import { isMissingColumnError, isMissingTableError } from "../../utils/db-errors.js";
import { encodeRev, validateRev } from "../rev.js";
import { decodeRev, encodeRev, validateRev } from "../rev.js";
import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js";
import { validateMediaFields } from "./validate-media-fields.js";

Expand All @@ -59,6 +63,15 @@ function hasApiError(error: unknown): error is Error & { apiError: { code: strin
);
}

function decodeRevisionPrecondition(
rev: string | undefined,
): ContentRevisionPrecondition | undefined {
if (rev === undefined) return undefined;
const decoded = decodeRev(rev);
if (!decoded) throw new ContentMutationConflictError("Revision precondition did not match");
return decoded;
}

/**
* Extract a slug source (title or name) from content data.
* Returns null if no suitable string field is found.
Expand Down Expand Up @@ -1584,9 +1597,11 @@ export async function handleContentPublish(
publishedAt?: string;
requireScheduledDue?: boolean;
expectedScheduledAt?: string;
_rev?: string;
} = {},
): Promise<ApiResult<ContentResponse>> {
try {
const expectedRevision = decodeRevisionPrecondition(options._rev);
const item = await withTransaction(db, async (trx) => {
const repo = new ContentRepository(trx);
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
Expand All @@ -1607,6 +1622,7 @@ export async function handleContentPublish(
options.expectedScheduledAt,
publishConfig.supportsRevisions,
publishConfig.routable,
expectedRevision,
);

// Leave a 301 behind when publishing changed the slug of an entry that
Expand All @@ -1629,7 +1645,7 @@ export async function handleContentPublish(

return {
success: true,
data: { item },
data: { item, _rev: encodeRev(item) },
};
} catch (error) {
if (error instanceof ContentMutationConflictError) {
Expand Down Expand Up @@ -1707,22 +1723,30 @@ export async function handleContentUnpublish(
db: Kysely<Database>,
collection: string,
id: string,
options: { _rev?: string } = {},
): Promise<ApiResult<ContentResponse>> {
try {
const expectedRevision = decodeRevisionPrecondition(options._rev);
const item = await withTransaction(db, async (trx) => {
const repo = new ContentRepository(trx);
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
return repo.unpublish(collection, resolvedId);
return repo.unpublish(collection, resolvedId, expectedRevision);
});

const hasSeo = await collectionHasSeo(db, collection);
await hydrateSeo(db, collection, item, hasSeo);

return {
success: true,
data: { item },
data: { item, _rev: encodeRev(item) },
};
} catch (error) {
if (error instanceof ContentMutationConflictError) {
return {
success: false,
error: { code: "CONFLICT", message: error.message },
};
}
if (error instanceof EmDashValidationError) {
return {
success: false,
Expand Down Expand Up @@ -1777,22 +1801,30 @@ export async function handleContentDiscardDraft(
db: Kysely<Database>,
collection: string,
id: string,
options: { _rev?: string } = {},
): Promise<ApiResult<ContentResponse>> {
try {
const expectedRevision = decodeRevisionPrecondition(options._rev);
const item = await withTransaction(db, async (trx) => {
const repo = new ContentRepository(trx);
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
return repo.discardDraft(collection, resolvedId);
return repo.discardDraft(collection, resolvedId, expectedRevision);
});

const hasSeo = await collectionHasSeo(db, collection);
await hydrateSeo(db, collection, item, hasSeo);

return {
success: true,
data: { item },
data: { item, _rev: encodeRev(item) },
};
} catch (error) {
if (error instanceof ContentMutationConflictError) {
return {
success: false,
error: { code: "CONFLICT", message: error.message },
};
}
if (error instanceof EmDashValidationError) {
return {
success: false,
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/api/schemas/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,15 @@ export const contentScheduleBody = z
})
.meta({ id: "ContentScheduleBody" });

export const contentPublishBody = z
.object({
export const contentRevisionConditionBody = z.object({
_rev: z
.string()
.optional()
.meta({ description: "Opaque revision token for optimistic concurrency" }),
});

export const contentPublishBody = contentRevisionConditionBody
.extend({
// .optional() rather than .nullish(): publishing has no semantic
// meaning for `null` (you can't "clear" a publish timestamp by
// publishing). Tightening the schema here means callers either
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@ import type { APIRoute } from "astro";

import { requireOwnerPerm } from "#api/authorize.js";
import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js";
import { isParseError, parseOptionalBody } from "#api/parse.js";
import { contentRevisionConditionBody } from "#api/schemas.js";

export const prerender = false;

export const POST: APIRoute = async ({ params, locals, url, cache }) => {
export const POST: APIRoute = async ({ params, request, locals, url, cache }) => {
const { emdash, user } = locals;
const collection = params.collection!;
const id = params.id!;

if (!emdash?.handleContentDiscardDraft || !emdash?.handleContentGet) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
}
const body = await parseOptionalBody(request, contentRevisionConditionBody, {});
if (isParseError(body)) return body;

const locale = url.searchParams.get("locale") || undefined;

Expand Down Expand Up @@ -48,7 +52,9 @@ export const POST: APIRoute = async ({ params, locals, url, cache }) => {

const resolvedId = typeof existingItem?.id === "string" ? existingItem.id : id;

const result = await emdash.handleContentDiscardDraft(collection, resolvedId);
const result = await emdash.handleContentDiscardDraft(collection, resolvedId, {
_rev: body?._rev,
});

if (!result.success) return unwrapResult(result);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* POST /_emdash/api/content/{collection}/{id}/publish
*
* Optional JSON body: { publishedAt?: string }
* Optional JSON body: { publishedAt?: string, _rev?: string }
* publishedAt — ISO 8601 datetime to backdate the publish (e.g. when
* migrating content). Writing publishedAt requires content:publish_any.
* Without it, the existing published_at is preserved on re-publish and
Expand Down Expand Up @@ -78,6 +78,7 @@ export const POST: APIRoute = async ({ params, request, locals, url, cache }) =>

const result = await emdash.handleContentPublish(collection, resolvedId, {
publishedAt,
_rev: body?._rev,
});

if (!result.success) return unwrapResult(result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@ import type { APIRoute } from "astro";

import { requireOwnerPerm } from "#api/authorize.js";
import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js";
import { isParseError, parseOptionalBody } from "#api/parse.js";
import { contentRevisionConditionBody } from "#api/schemas.js";

export const prerender = false;

export const POST: APIRoute = async ({ params, locals, url, cache }) => {
export const POST: APIRoute = async ({ params, request, locals, url, cache }) => {
const { emdash, user } = locals;
const collection = params.collection!;
const id = params.id!;

if (!emdash?.handleContentUnpublish || !emdash?.handleContentGet) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
}
const body = await parseOptionalBody(request, contentRevisionConditionBody, {});
if (isParseError(body)) return body;

const locale = url.searchParams.get("locale") || undefined;

Expand Down Expand Up @@ -48,7 +52,7 @@ export const POST: APIRoute = async ({ params, locals, url, cache }) => {

const resolvedId = typeof existingItem?.id === "string" ? existingItem.id : id;

const result = await emdash.handleContentUnpublish(collection, resolvedId);
const result = await emdash.handleContentUnpublish(collection, resolvedId, { _rev: body?._rev });

if (!result.success) return unwrapResult(result);

Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,14 @@ export interface EmDashHandlers {
handleContentPublish: (
collection: string,
id: string,
options?: { publishedAt?: string; requireScheduledDue?: boolean },
options?: { publishedAt?: string; requireScheduledDue?: boolean; _rev?: string },
) => Promise<HandlerResponse>;

handleContentUnpublish: (collection: string, id: string) => Promise<HandlerResponse>;
handleContentUnpublish: (
collection: string,
id: string,
options?: { _rev?: string },
) => Promise<HandlerResponse>;

handleContentSchedule: (
collection: string,
Expand All @@ -365,7 +369,11 @@ export interface EmDashHandlers {

handleContentCountScheduled: (collection: string) => Promise<HandlerResponse>;

handleContentDiscardDraft: (collection: string, id: string) => Promise<HandlerResponse>;
handleContentDiscardDraft: (
collection: string,
id: string,
options?: { _rev?: string },
) => Promise<HandlerResponse>;

handleContentCompare: (collection: string, id: string) => Promise<HandlerResponse>;

Expand Down
Loading
Loading