Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ba4646a
feat(core): add guarded collection deletion foundation
khoinguyenpham04 Aug 11, 2026
302ac9e
feat(core): safely detach activated collections
khoinguyenpham04 Aug 11, 2026
a576d10
feat(core): process bounded collection deletion cleanup
khoinguyenpham04 Aug 11, 2026
83dbf0c
feat(core): expose collection deletion recovery controls
khoinguyenpham04 Aug 11, 2026
2d36e13
fix(cloudflare): harden collection deletion guards
khoinguyenpham04 Aug 12, 2026
725ef86
fix(core): bound collection deletion completion checks
khoinguyenpham04 Aug 12, 2026
110f945
fix(core): use database time for deletion progress
khoinguyenpham04 Aug 12, 2026
1d81a96
chore(core): register collection deletion schemas
khoinguyenpham04 Aug 12, 2026
808d03e
chore(core): keep deletion lease guards transaction-scoped
khoinguyenpham04 Aug 12, 2026
26e4b26
fix(core): preserve collection deletion compatibility
khoinguyenpham04 Aug 12, 2026
90f01f8
fix(cloudflare): pin mutation reads to DO primary
khoinguyenpham04 Aug 12, 2026
e2a18b6
feat(media): add reconciliation coordinator state
khoinguyenpham04 Aug 12, 2026
136ece4
feat(media): add bounded reconciliation scan
khoinguyenpham04 Aug 12, 2026
e3d35a2
feat(media): finalize automatic reconciliation
khoinguyenpham04 Aug 12, 2026
da3101d
feat(media): schedule automatic reconciliation
khoinguyenpham04 Aug 12, 2026
5ce7f25
fix(media): preserve scheduled maintenance compatibility
khoinguyenpham04 Aug 12, 2026
da4e77e
Merge remote-tracking branch 'origin/main' into feature/media-usage-a…
khoinguyenpham04 Aug 12, 2026
8c8c0e0
docs: clarify automatic reconciliation changeset
khoinguyenpham04 Aug 12, 2026
1a96dda
fix(core): guard null media usage revisions on postgres
khoinguyenpham04 Aug 12, 2026
7a3ff56
fix(cloudflare): default scheduled cron routing
khoinguyenpham04 Aug 13, 2026
29ae696
docs(cloudflare): keep worker comments current
khoinguyenpham04 Aug 13, 2026
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
6 changes: 6 additions & 0 deletions .changeset/calm-files-reconcile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": patch
"@emdash-cms/cloudflare": patch
---

Adds automatic, resumable background indexing so Media Usage can safely catch up on existing content without processing the whole site at once.
5 changes: 4 additions & 1 deletion demos/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,8 @@ export { PluginBridge };

export default {
...handler,
scheduled: createScheduledHandler(),
scheduled: createScheduledHandler({
generalCron: "* * * * *",
mediaUsageCron: "*/2 * * * *",
}),
} satisfies ExportedHandler<Env>;
2 changes: 1 addition & 1 deletion demos/cloudflare/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
],
// Cron trigger drives the AI Search reindex queue flush.
"triggers": {
"crons": ["* * * * *"],
"crons": ["* * * * *", "*/2 * * * *"],
},
// Observability
"observability": {
Expand Down
23 changes: 18 additions & 5 deletions docs/src/content/docs/deployment/cloudflare.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,24 +74,37 @@ To change the schema or content model of a site that is already deployed, see [E

## Scheduled Publishing

On Cloudflare Workers, scheduled publishing, plugin cron, and maintenance tasks run from a Worker Cron Trigger. New Cloudflare templates include this setup automatically. If you are adding updating an existing project, export the EmDash Worker entry from `@emdash-cms/cloudflare/worker`:
On Cloudflare Workers, scheduled publishing, plugin cron, and maintenance tasks run from Worker Cron Triggers. New Cloudflare templates include both required schedules automatically. When updating an existing project, configure distinct general and Media Usage lanes:

```ts title="src/worker.ts"
export { default, PluginBridge } from "@emdash-cms/cloudflare/worker";
import handler, {
createScheduledHandler,
PluginBridge,
} from "@emdash-cms/cloudflare/worker";

export { PluginBridge };

export default {
...handler,
scheduled: createScheduledHandler({
generalCron: "* * * * *",
mediaUsageCron: "*/2 * * * *",
}),
Comment thread
khoinguyenpham04 marked this conversation as resolved.
Outdated
} satisfies ExportedHandler;
```

Then add a Cron Trigger to `wrangler.jsonc`:
Then add both Cron Triggers to `wrangler.jsonc` using the same expressions:

```jsonc title="wrangler.jsonc"
{
"triggers": {
"crons": ["* * * * *"],
"crons": ["* * * * *", "*/2 * * * *"],
},
}
```

<Aside type="caution">
Without the Cron Trigger, content scheduled in the admin panel does not publish on Cloudflare Workers, and plugin cron jobs do not run. Local `astro dev` still uses the in-process scheduler.
Without the general trigger, scheduled publishing and plugin cron do not run. Without the dedicated Media Usage trigger, automatic historical reconciliation cannot progress. Local `astro dev` still uses the in-process scheduler.
</Aside>

## Deploy
Expand Down
59 changes: 46 additions & 13 deletions packages/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@
* Cloudflare Worker entry for EmDash sites.
*
* Wraps the Astro Cloudflare server handler with a `scheduled()` handler so a
* Cron Trigger drives scheduled publishing, plugin cron, and system cleanup
* without any request side effects. Re-exports the `PluginBridge` Durable
* Object so the sandbox binding resolves against the entry module.
* Cron Triggers drive general maintenance and the separately bounded Media
* Usage lane without request side effects. Re-exports the `PluginBridge`
* Durable Object so the sandbox binding resolves against the entry module.
*
* Templates use this as their entire `src/worker.ts`:
* Existing sites can keep the default general-maintenance handler:
Comment thread
khoinguyenpham04 marked this conversation as resolved.
Outdated
*
* export { default, PluginBridge } from "@emdash-cms/cloudflare/worker";
*
* and add a Cron Trigger to wrangler.jsonc:
* New sites configure distinct expressions through `createScheduledHandler`.
Comment thread
khoinguyenpham04 marked this conversation as resolved.
Outdated
*
* "triggers": { "crons": ["* * * * *"] }
* Configure one general expression and one distinct Media Usage expression.
*
* The `@astrojs/cloudflare/entrypoints/server` import is resolved by the
* consuming app's Astro build (it pulls the build-time `virtual:astro:app`
Expand All @@ -22,7 +22,7 @@
// @ts-ignore - resolved against the consuming app's Astro build
import astroHandler from "@astrojs/cloudflare/entrypoints/server";
import { createApp } from "astro/app/entrypoint";
import { runScheduledTasks } from "emdash/middleware";
import { runScheduledMediaUsageTasks, runScheduledTasks } from "emdash/middleware";

export { PluginBridge } from "./sandbox/index.js";

Expand All @@ -48,13 +48,46 @@ async function invalidatePublishedTags(
}

/**
* Build a Worker `scheduled()` handler that runs EmDash's scheduled
* maintenance batch and purges edge-cache tags for anything it published.
* Exported for sites that assemble their own Worker object; most sites get it
* via this module's default export.
* Build a Worker `scheduled()` handler. Without options every expression runs
* the backwards-compatible general lane. Configured handlers dispatch exact,
* distinct expressions to general or Media Usage maintenance.
*/
export function createScheduledHandler(): ExportedHandlerScheduledHandler {
return (_controller, _env, ctx) => {
export interface ScheduledHandlerOptions {
generalCron: string;
mediaUsageCron: string;
}

export function createScheduledHandler(
options?: ScheduledHandlerOptions,
): ExportedHandlerScheduledHandler {
if (options) {
if (!options.generalCron.trim() || !options.mediaUsageCron.trim()) {
throw new Error("Configured scheduled-handler expressions must be non-empty");
}
if (options.generalCron === options.mediaUsageCron) {
throw new Error("General and Media Usage Cron expressions must differ");
}
}
return (controller, _env, ctx) => {
if (options && controller.cron === options.mediaUsageCron) {
ctx.waitUntil(
runScheduledMediaUsageTasks().catch((error: unknown) => {
console.error("[scheduled] Media Usage maintenance failed:", error);
}),
);
return;
}
if (options && controller.cron !== options.generalCron) {
console.warn(`[scheduled] Ignoring unexpected Cron expression: ${controller.cron}`);
return;
}
if (!options) {
ctx.waitUntil(
runScheduledMediaUsageTasks().catch((error: unknown) => {
console.error("[scheduled] Media Usage maintenance failed:", error);
}),
);
}
ctx.waitUntil(
// Invalidate incrementally as each collection batch publishes, so a
// scheduled() invocation killed mid-sweep (CPU/wall-clock limits on a
Expand Down
70 changes: 70 additions & 0 deletions packages/cloudflare/tests/worker-scheduled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { beforeEach, expect, it, vi } from "vitest";

const scheduled = vi.hoisted(() => ({
general: vi.fn(async () => ({ published: [] })),
mediaUsage: vi.fn(async () => ({ outcome: "inactive", taskClass: null, turn: null })),
}));

vi.mock("@astrojs/cloudflare/entrypoints/server", () => ({ default: { fetch: vi.fn() } }));
vi.mock("astro/app/entrypoint", () => ({
createApp: () => ({ pipeline: { getCacheProvider: async () => null } }),
}));
vi.mock("emdash/middleware", () => ({
runScheduledTasks: scheduled.general,
runScheduledMediaUsageTasks: scheduled.mediaUsage,
}));
vi.mock("../src/sandbox/index.js", () => ({ PluginBridge: vi.fn() }));

import { createScheduledHandler } from "../src/worker.js";

beforeEach(() => {
scheduled.general.mockClear();
scheduled.mediaUsage.mockClear();
});

it("keeps the unconfigured handler backwards-compatible", async () => {
await invoke(createScheduledHandler(), "custom expression");
expect(scheduled.general).toHaveBeenCalledOnce();
expect(scheduled.mediaUsage).toHaveBeenCalledOnce();
});

it("dispatches distinct configured cron expressions to exactly one lane", async () => {
const handler = createScheduledHandler({
generalCron: "* * * * *",
mediaUsageCron: "*/2 * * * *",
});

await invoke(handler, "* * * * *");
expect(scheduled.general).toHaveBeenCalledOnce();
expect(scheduled.mediaUsage).not.toHaveBeenCalled();

scheduled.general.mockClear();
await invoke(handler, "*/2 * * * *");
expect(scheduled.general).not.toHaveBeenCalled();
expect(scheduled.mediaUsage).toHaveBeenCalledOnce();

scheduled.mediaUsage.mockClear();
await invoke(handler, "0 0 * * *");
expect(scheduled.general).not.toHaveBeenCalled();
expect(scheduled.mediaUsage).not.toHaveBeenCalled();
});

it("rejects empty or aliased configured expressions", () => {
expect(() =>
createScheduledHandler({ generalCron: "* * * * *", mediaUsageCron: "* * * * *" }),
).toThrow(/must differ/i);
expect(() => createScheduledHandler({ generalCron: "", mediaUsageCron: "*/2 * * * *" })).toThrow(
/non-empty/i,
);
});

async function invoke(handler: ExportedHandlerScheduledHandler, cron: string): Promise<void> {
const pending: Promise<unknown>[] = [];
const context = {
waitUntil(promise: Promise<unknown>) {
pending.push(promise);
},
};
Reflect.apply(handler, undefined, [{ cron }, {}, context]);
await Promise.all(pending);
}
37 changes: 36 additions & 1 deletion packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ import {
flushRecorder,
isInstrumentationEnabled,
} from "../database/instrumentation.js";
import { createDeferredTaskTracker } from "../deferred-tasks.js";
import {
DB_INIT_DEADLINE_MS,
EmDashRuntime,
type MediaUsageMaintenanceResult,
type RuntimeDependencies,
type SandboxedPluginEntry,
type MediaProviderEntry,
Expand Down Expand Up @@ -287,6 +289,12 @@ export async function runScheduledTasks(
return runOutsideRequest(config, (runtime) => runtime.runScheduledTasks(options));
}

export async function runScheduledMediaUsageTasks(): Promise<MediaUsageMaintenanceResult> {
const config = getConfig();
if (!config) return { outcome: "inactive", taskClass: null, turn: null };
return runOutsideRequest(config, (runtime) => runtime.runScheduledMediaUsageTasks());
}

/**
* Run a callback against the EmDash runtime outside any HTTP request — from a
* Cloudflare Queue consumer, a `scheduled()` handler, or any other
Expand Down Expand Up @@ -352,8 +360,35 @@ async function runOutsideRequest<T>(
config: EmDashConfig,
fn: (runtime: EmDashRuntime) => Promise<T>,
): Promise<T> {
const runtime = await getRuntime(config);
if (getRequestContext()) {
const runtime = await getRuntime(config);
return runOutsideRequestWithRuntime(config, runtime, fn);
}

const deferredTasks = createDeferredTaskTracker(() => {});
const context = {
editMode: false,
metrics: createRequestMetrics(performance.now()),
deferredTasks,
};
return runWithContext(context, async () => {
const runtime = await (async () => {
try {
return await getRuntime(config);
} finally {
deferredTasks.settle();
await deferredTasks.settled;
}
})();
return runOutsideRequestWithRuntime(config, runtime, fn);
});
}

async function runOutsideRequestWithRuntime<T>(
config: EmDashConfig,
runtime: EmDashRuntime,
fn: (runtime: EmDashRuntime) => Promise<T>,
): Promise<T> {
const scoped = createRequestScopedDb({
config: config.database?.config,
isAuthenticated: false,
Expand Down
Loading
Loading