Skip to content

Commit c1f6768

Browse files
scottbuscemikhoinguyenpham04emdashbot[bot]
authored
fix: skip edge cache invalidate on draft-only content saves (#2279)
* fix: skip edge cache invalidate on draft-only content saves Pure draft staging on revision collections leaves live public HTML unchanged; thrashing edge tags can reseed stale Workers Cache entries. * style(core): drop justification comment on edge invalidate gate * Update packages/core/src/astro/types.ts Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com> * fix: keep draft-only saves out of public caches --------- Co-authored-by: Noah (Nguyen Pham) <137921741+khoinguyenpham04@users.noreply.github.com> Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com> Co-authored-by: Noah Pham <ptknguyen04@gmail.com>
1 parent b6d3d83 commit c1f6768

9 files changed

Lines changed: 456 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Stops draft-only content saves from purging public caches and removes internal revision IDs from anonymous content query results. Public caches are invalidated when live content changes, while preview and edit requests retain the revision metadata needed to load drafts.

packages/core/src/astro/routes/api/content/[collection]/[id].ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => {
126126

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

129-
if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
129+
if (cache?.enabled && result.liveContentChanged !== false) {
130+
await cache.invalidate({ tags: [collection, resolvedId] });
131+
}
130132

131133
return unwrapResult(result);
132134
};

packages/core/src/astro/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ export interface EmDashManifest {
210210
export interface HandlerResponse<T = unknown> {
211211
success: boolean;
212212
data?: T;
213+
liveContentChanged?: boolean;
213214
error?: {
214215
code: string;
215216
message: string;

packages/core/src/database/repositories/content.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,7 @@ export class ContentRepository {
660660
await this.restampEntryPivot(type, id);
661661
}
662662

663-
invalidateCollectionCache(type);
663+
if (hasColumnWrites) invalidateCollectionCache(type);
664664

665665
const updated = await this.findById(type, id);
666666
if (!updated) {
@@ -1555,8 +1555,6 @@ export class ContentRepository {
15551555
WHERE id = ${id}
15561556
AND deleted_at IS NULL
15571557
`.execute(this.db);
1558-
1559-
invalidateCollectionCache(type);
15601558
}
15611559

15621560
/**
@@ -1588,8 +1586,6 @@ export class ContentRepository {
15881586
AND deleted_at IS NULL
15891587
`.execute(this.db);
15901588

1591-
invalidateCollectionCache(type);
1592-
15931589
const updated = await this.findById(type, id);
15941590
if (!updated) {
15951591
throw new Error("Content not found");

packages/core/src/emdash-runtime.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ const FIELD_TYPE_TO_KIND: Record<FieldType, string> = {
225225
repeater: "repeater",
226226
};
227227

228+
const DRAFT_ONLY_UPDATE_KEYS = new Set(["data", "slug", "locale", "skipRevision"]);
229+
228230
/**
229231
* Sandboxed plugin entry from virtual module
230232
*/
@@ -2909,6 +2911,14 @@ export class EmDashRuntime {
29092911
bylines: bodyWithoutRev.bylines,
29102912
});
29112913

2914+
// Public HTML comes from live columns / SEO / taxonomies, not draft revisions.
2915+
const liveMetaTouched = Object.entries(bodyWithoutRev).some(
2916+
([key, value]) => value !== undefined && !DRAFT_ONLY_UPDATE_KEYS.has(key),
2917+
);
2918+
const liveContentChanged = usesDraftRevisions
2919+
? liveMetaTouched
2920+
: Boolean(processedData || bodyWithoutRev.slug !== undefined || liveMetaTouched);
2921+
29122922
// Hydrate draft data BEFORE firing afterSave hooks so the hook sees
29132923
// the same effective data the response surfaces — for revision-
29142924
// supporting collections, that's the just-saved draft, not the live
@@ -2957,6 +2967,9 @@ export class EmDashRuntime {
29572967
this.runAfterSaveHooks(contentItemToRecord(hydrated.data.item), collection, false);
29582968
}
29592969

2970+
if (hydrated.success) {
2971+
return { ...hydrated, liveContentChanged };
2972+
}
29602973
return hydrated;
29612974
}
29622975

packages/core/src/query.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,23 @@ function entryEditOptions(entry: { data?: unknown }): EditableOptions {
347347
return { status, hasDraft };
348348
}
349349

350+
function stripRevisionMetadata(entry: { data?: unknown }): void {
351+
const data = entryData(entry);
352+
delete data.draftRevisionId;
353+
delete data.liveRevisionId;
354+
}
355+
356+
function canExposeRevisionMetadata(
357+
entry: { id: string; data?: unknown },
358+
collection: string,
359+
): boolean {
360+
const ctx = getRequestContext();
361+
if (ctx?.editMode) return true;
362+
if (ctx?.preview?.collection !== collection) return false;
363+
const dbId = entryDatabaseId(entry);
364+
return ctx.preview.id === dbId || ctx.preview.id === entry.id;
365+
}
366+
350367
/**
351368
* Get all entries of a content type
352369
*
@@ -441,7 +458,11 @@ async function loadCollectionCached<T extends string, D = InferCollectionData<T>
441458
return { entries: [], error: snapshot.error, cacheHint: snapshot.cacheHint };
442459
}
443460
return {
444-
entries: snapshot.value.entries.map((entry) => reviveEntry<D>(entry)),
461+
entries: snapshot.value.entries.map((entry) => {
462+
const revived = reviveEntry<D>(entry);
463+
if (!canExposeRevisionMetadata(revived, type)) stripRevisionMetadata(revived);
464+
return revived;
465+
}),
445466
nextCursor: snapshot.value.nextCursor,
446467
hasMore: snapshot.value.hasMore,
447468
cacheHint: snapshot.value.cacheHint,
@@ -729,6 +750,9 @@ async function getEmDashCollectionUncached<T extends string, D = InferCollection
729750
if (isEditMode) {
730751
tagEditableFields(entryData(entry), type, dbId);
731752
}
753+
if (!canExposeRevisionMetadata(entry, type)) {
754+
stripRevisionMetadata(entry);
755+
}
732756
return {
733757
...entry,
734758
edit: isEditMode ? createEditable(type, dbId, entryEditOptions(entry)) : createNoop(),
@@ -833,6 +857,7 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T
833857
wrapped: ContentEntry<D>,
834858
opts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint },
835859
): Promise<EntryResult<D>> {
860+
if (!opts.isPreview) stripRevisionMetadata(wrapped);
836861
// Hydrate terms in the entry's resolved locale (fallback-aware) so a
837862
// localized entry never picks up default-locale taxonomy terms (#1441).
838863
// When i18n is disabled we leave the locale unset to preserve the
@@ -984,8 +1009,10 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T
9841009
if (!snapshot.ok) {
9851010
return { entry: null, error: snapshot.error, isPreview: false, cacheHint: snapshot.cacheHint };
9861011
}
1012+
const revived = snapshot.value.entry ? reviveEntry<D>(snapshot.value.entry) : null;
1013+
if (revived && !canExposeRevisionMetadata(revived, type)) stripRevisionMetadata(revived);
9871014
return {
988-
entry: snapshot.value.entry ? reviveEntry<D>(snapshot.value.entry) : null,
1015+
entry: revived,
9891016
isPreview: snapshot.value.isPreview,
9901017
fallbackLocale: snapshot.value.fallbackLocale,
9911018
cacheHint: snapshot.value.cacheHint,
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* handleContentUpdate reports liveContentChanged so edge invalidation can
3+
* skip pure draft staging on revision-supporting collections.
4+
*/
5+
6+
import type { Kysely } from "kysely";
7+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
8+
9+
import type { Database } from "../../../src/database/types.js";
10+
import type { EmDashRuntime } from "../../../src/emdash-runtime.js";
11+
import { SchemaRegistry } from "../../../src/schema/registry.js";
12+
import { createTestRuntime } from "../../utils/mcp-runtime.js";
13+
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
14+
15+
describe("handleContentUpdate liveContentChanged", () => {
16+
let db: Kysely<Database>;
17+
let runtime: EmDashRuntime;
18+
19+
beforeEach(async () => {
20+
db = await setupTestDatabase();
21+
const registry = new SchemaRegistry(db);
22+
await registry.createCollection({ slug: "posts", label: "Posts" });
23+
await registry.createField("posts", { slug: "title", label: "Title", type: "string" });
24+
await registry.createCollection({
25+
slug: "plain_posts",
26+
label: "Plain Posts",
27+
supports: [],
28+
});
29+
await registry.createField("plain_posts", {
30+
slug: "title",
31+
label: "Title",
32+
type: "string",
33+
});
34+
runtime = createTestRuntime(db);
35+
});
36+
37+
afterEach(async () => {
38+
await teardownTestDatabase(db);
39+
});
40+
41+
it("is false for draft-only data save on revision collections", async () => {
42+
const created = await runtime.handleContentCreate("posts", {
43+
data: { title: "Live" },
44+
slug: "live",
45+
});
46+
expect(created.success).toBe(true);
47+
const id = created.data!.item.id;
48+
await runtime.handleContentPublish("posts", id);
49+
50+
const saved = await runtime.handleContentUpdate("posts", id, {
51+
data: { title: "Draft edit" },
52+
});
53+
expect(saved.success).toBe(true);
54+
expect(saved.success && saved.liveContentChanged).toBe(false);
55+
});
56+
57+
it("is false when data+slug are staged as a draft revision", async () => {
58+
const created = await runtime.handleContentCreate("posts", {
59+
data: { title: "Live" },
60+
slug: "live",
61+
});
62+
const id = created.data!.item.id;
63+
await runtime.handleContentPublish("posts", id);
64+
65+
const saved = await runtime.handleContentUpdate("posts", id, {
66+
data: { title: "Live" },
67+
slug: "live-renamed",
68+
});
69+
expect(saved.success).toBe(true);
70+
expect(saved.success && saved.liveContentChanged).toBe(false);
71+
});
72+
73+
it("is true when live metadata changes on a revision collection", async () => {
74+
const created = await runtime.handleContentCreate("posts", {
75+
data: { title: "Live" },
76+
slug: "live-meta",
77+
});
78+
const id = created.data!.item.id;
79+
await runtime.handleContentPublish("posts", id);
80+
81+
const saved = await runtime.handleContentUpdate("posts", id, {
82+
publishedAt: "2020-01-01T00:00:00.000Z",
83+
});
84+
expect(saved.success).toBe(true);
85+
expect(saved.success && saved.liveContentChanged).toBe(true);
86+
});
87+
88+
it("defaults unclassified update fields to live-changing", async () => {
89+
const created = await runtime.handleContentCreate("posts", {
90+
data: { title: "Live" },
91+
slug: "future-meta",
92+
});
93+
const id = created.data!.item.id;
94+
await runtime.handleContentPublish("posts", id);
95+
96+
const saved = await runtime.handleContentUpdate("posts", id, {
97+
// @ts-expect-error - simulates a future live field before it joins the public input type
98+
futureLiveField: "changed",
99+
});
100+
expect(saved.success).toBe(true);
101+
expect(saved.success && saved.liveContentChanged).toBe(true);
102+
});
103+
104+
it("is true for data updates on collections without revisions", async () => {
105+
const created = await runtime.handleContentCreate("plain_posts", {
106+
data: { title: "Plain" },
107+
slug: "plain",
108+
});
109+
const id = created.data!.item.id;
110+
111+
const updated = await runtime.handleContentUpdate("plain_posts", id, {
112+
data: { title: "Plain edited" },
113+
});
114+
expect(updated.success).toBe(true);
115+
expect(updated.success && updated.liveContentChanged).toBe(true);
116+
});
117+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* PUT content updates must only purge edge cache tags when live/public
3+
* content actually changed. Pure draft staging on revision collections
4+
* leaves live columns untouched and must not thrash Workers Cache.
5+
*/
6+
7+
import { Role } from "@emdash-cms/auth";
8+
import { describe, it, expect, vi } from "vitest";
9+
10+
import { PUT as updateContent } from "../../../src/astro/routes/api/content/[collection]/[id].js";
11+
12+
describe("PUT content route — edge cache invalidation", () => {
13+
const makeUser = () => ({
14+
id: "user-1",
15+
role: Role.EDITOR,
16+
});
17+
18+
const ownedItem = {
19+
success: true as const,
20+
data: { item: { id: "c1", authorId: "user-1" }, _rev: "rev1" },
21+
};
22+
23+
async function putWithUpdateResult(
24+
updateResult: {
25+
success: boolean;
26+
data?: unknown;
27+
liveContentChanged?: boolean;
28+
error?: { code: string; message: string };
29+
},
30+
body: Record<string, unknown> = { data: { title: "Edited" } },
31+
) {
32+
const handleContentGet = vi.fn().mockResolvedValue(ownedItem);
33+
const handleContentUpdate = vi.fn().mockResolvedValue(updateResult);
34+
const invalidate = vi.fn().mockResolvedValue(undefined);
35+
36+
const request = new Request("http://localhost/_emdash/api/content/posts/c1", {
37+
method: "PUT",
38+
headers: { "Content-Type": "application/json" },
39+
body: JSON.stringify(body),
40+
});
41+
42+
const response = await updateContent({
43+
params: { collection: "posts", id: "c1" },
44+
request,
45+
url: new URL(request.url),
46+
locals: {
47+
emdash: { handleContentUpdate, handleContentGet },
48+
user: makeUser(),
49+
},
50+
cache: { enabled: true, invalidate },
51+
} as Parameters<typeof updateContent>[0]);
52+
53+
return { response, invalidate, handleContentUpdate };
54+
}
55+
56+
it("does not invalidate when liveContentChanged is false (draft-only save)", async () => {
57+
const { response, invalidate } = await putWithUpdateResult({
58+
success: true,
59+
data: { item: { id: "c1" }, _rev: "rev2" },
60+
liveContentChanged: false,
61+
});
62+
63+
expect(response.status).toBe(200);
64+
expect(invalidate).not.toHaveBeenCalled();
65+
const json = await response.json();
66+
expect(json).toMatchObject({ success: true, data: { item: { id: "c1" } } });
67+
expect(json).not.toHaveProperty("liveContentChanged");
68+
expect(json.data).not.toHaveProperty("liveContentChanged");
69+
});
70+
71+
it("invalidates when liveContentChanged is true", async () => {
72+
const { response, invalidate } = await putWithUpdateResult({
73+
success: true,
74+
data: { item: { id: "c1" }, _rev: "rev2" },
75+
liveContentChanged: true,
76+
});
77+
78+
expect(response.status).toBe(200);
79+
expect(invalidate).toHaveBeenCalledTimes(1);
80+
expect(invalidate).toHaveBeenCalledWith({ tags: ["posts", "c1"] });
81+
});
82+
83+
it("invalidates when liveContentChanged is omitted (safe default)", async () => {
84+
const { response, invalidate } = await putWithUpdateResult({
85+
success: true,
86+
data: { item: { id: "c1" }, _rev: "rev2" },
87+
});
88+
89+
expect(response.status).toBe(200);
90+
expect(invalidate).toHaveBeenCalledWith({ tags: ["posts", "c1"] });
91+
});
92+
93+
it("does not invalidate on failed updates", async () => {
94+
const { response, invalidate } = await putWithUpdateResult({
95+
success: false,
96+
error: { code: "CONFLICT", message: "stale" },
97+
liveContentChanged: true,
98+
});
99+
100+
expect(response.status).toBe(409);
101+
expect(invalidate).not.toHaveBeenCalled();
102+
});
103+
});

0 commit comments

Comments
 (0)