Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/fix-scheduled-publishing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"emdash": patch
---

Fix scheduled posts never becoming published. Two independent bugs:

1. **No auto-publish mechanism** -- `ContentRepository.findReadyToPublish()` existed but was never called outside tests. Added `publishScheduledContent()` that runs on every cron tick (Node scheduler or Cloudflare piggyback), iterates all collections, and publishes items whose `scheduled_at` has passed via the standard `publish()` path. Also wired `runtime.tickCron()` into middleware so the piggyback scheduler actually fires on Cloudflare Workers.

2. **SQLite format mismatch (fixes #917)** -- `scheduled_at` is stored as ISO 8601 with `T` and `Z` (e.g. `2026-05-05T01:41:59.000Z`) but SQLite's `datetime('now')` returns `YYYY-MM-DD HH:MM:SS`. Lexicographic comparison sees `T` (0x54) > space (0x20), so `scheduled_at <= datetime('now')` was always false. Fixed by wrapping both sides in `datetime()` on SQLite in `loader.ts`, `content.ts`, and `snapshot.ts`.
10 changes: 8 additions & 2 deletions packages/core/src/api/handlers/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import type { Kysely } from "kysely";
import { sql } from "kysely";

import { currentTimestampValue, isSqlite } from "../../database/dialect-helpers.js";
import type { Database } from "../../database/types.js";

// ─�� Preview signature verification ──────────────────────────────
Expand Down Expand Up @@ -289,12 +290,17 @@ export async function generateSnapshot(
`.execute(db)
).rows;
} else {
// Only export published content
// Only export published content.
// On SQLite, wrap scheduled_at in datetime() to normalize
// ISO 8601 "T"/"Z" format for comparison with datetime('now').
const scheduledAtExpr = isSqlite(db)
? sql`datetime(scheduled_at)`
: sql`scheduled_at::timestamptz`;
rows = (
await sql<Record<string, unknown>>`
SELECT * FROM ${sql.raw(`"${tableName}"`)}
WHERE deleted_at IS NULL
AND (status = 'published' OR (status = 'scheduled' AND scheduled_at <= datetime('now')))
AND (status = 'published' OR (status = 'scheduled' AND ${scheduledAtExpr} <= ${currentTimestampValue(db)}))
`.execute(db)
).rows;
}
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { sandboxedPlugins as virtualSandboxedPlugins } from "virtual:emdash/sand
// @ts-ignore - virtual module
import { createStorage as virtualCreateStorage } from "virtual:emdash/storage";

import { after } from "../after.js";
import { publishScheduledContent } from "../cleanup.js";
import {
createRecorder,
flushRecorder,
Expand Down Expand Up @@ -58,6 +60,10 @@ import type { EmDashHandlers } from "./types.js";
let runtimeInstance: EmDashRuntime | null = null;
// Whether initialization is in progress (prevents concurrent init attempts)
let runtimeInitializing = false;
// Debounce timestamp for scheduled content publishing (ms since epoch)
let lastScheduledPublishAt = 0;
/** Minimum interval between scheduled publish checks (ms) */
const SCHEDULED_PUBLISH_DEBOUNCE_MS = 15_000;

/** Whether i18n config has been initialized from the virtual module */
let i18nInitialized = false;
Expand Down Expand Up @@ -548,6 +554,28 @@ export const onRequest = defineMiddleware(async (context, next) => {
// Update plugin enabled/disabled status and rebuild hook pipeline
setPluginStatus: runtime.setPluginStatus.bind(runtime),
};

// Tick the cron scheduler so the piggyback path (Cloudflare
// Workers) processes plugin cron tasks and system cleanup
// on each request (debounced to at most once per 60 s).
runtime.tickCron();

// Publish scheduled content independently of the cron system.
// Uses after() so it doesn't block the response and properly
// extends the Worker lifetime via waitUntil on Cloudflare.
// Debounced to avoid running on every single request.
const now = Date.now();
if (now - lastScheduledPublishAt >= SCHEDULED_PUBLISH_DEBOUNCE_MS) {
lastScheduledPublishAt = now;
const db = runtime.db;
after(async () => {
try {
await publishScheduledContent(db);
} catch (error) {
console.error("[scheduled] Scheduled content publishing failed:", error);
}
});
}
} catch (error) {
console.error("EmDash middleware error:", error);
}
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import { createKyselyAdapter, type AuthTables } from "@emdash-cms/auth/adapters/
import { sql, type Kysely } from "kysely";

import { cleanupExpiredChallenges } from "./auth/challenge-store.js";
import { ContentRepository } from "./database/repositories/content.js";
import { MediaRepository } from "./database/repositories/media.js";
import { RevisionRepository } from "./database/repositories/revision.js";
import { withTransaction } from "./database/transaction.js";
import type { Database } from "./database/types.js";
import type { Storage } from "./storage/types.js";
import { isMissingTableError } from "./utils/db-errors.js";

/**
* Result of a system cleanup run.
Expand Down Expand Up @@ -151,3 +154,78 @@ async function pruneExcessiveRevisions(db: Kysely<Database>): Promise<number> {

return totalPruned;
}

// ─── Scheduled Content Publishing ──────────────────────────────────────────

/**
* Result of a scheduled content publishing run.
*/
export interface PublishScheduledResult {
/** Total items published across all collections */
published: number;
/** Total items that failed to publish */
failed: number;
}

/**
* Publish all content whose scheduled_at time has passed.
*
* Iterates over every registered collection, finds items where
* `scheduled_at <= now`, and promotes each to published status via the
* standard `ContentRepository.publish()` path (which handles revision
* promotion, data sync, and clearing the schedule).
*
* Safe to call frequently -- when nothing is due, the only cost is one
* lightweight SELECT per collection plus the collections list query.
*
* Each item is published independently; a failure on one item does not
* prevent the rest from being processed.
*/
export async function publishScheduledContent(
db: Kysely<Database>,
): Promise<PublishScheduledResult> {
const result: PublishScheduledResult = { published: 0, failed: 0 };

// Discover all registered collections
let collectionSlugs: string[];
try {
const rows = await db.selectFrom("_emdash_collections").select("slug").execute();
collectionSlugs = rows.map((r) => r.slug);
} catch (error) {
// Pre-migration database or missing table -- nothing to publish
if (isMissingTableError(error)) return result;
throw error;
}

if (collectionSlugs.length === 0) return result;

const repo = new ContentRepository(db);

for (const slug of collectionSlugs) {
let readyItems;
try {
readyItems = await repo.findReadyToPublish(slug);
} catch (error) {
// Table may have been dropped between listing and querying
if (isMissingTableError(error)) continue;
console.error(`[scheduled] Failed to query scheduled content for ${slug}:`, error);
continue;
}

for (const item of readyItems) {
try {
await withTransaction(db, async (trx) => {
const txRepo = new ContentRepository(trx);
await txRepo.publish(slug, item.id);
});
result.published++;
console.log(`[scheduled] Published ${slug}/${item.id} (scheduled_at: ${item.scheduledAt})`);
} catch (error) {
result.failed++;
console.error(`[scheduled] Failed to publish ${slug}/${item.id}:`, error);
}
}
}

return result;
}
13 changes: 11 additions & 2 deletions packages/core/src/database/repositories/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,15 +893,24 @@ export class ContentRepository {
* Returns all content where scheduled_at <= now, regardless of status.
* This covers both draft-scheduled posts (status='scheduled') and
* published posts with scheduled draft changes (status='published').
*
* Uses datetime() on both sides for SQLite to normalize the ISO 8601
* "T"/"Z" format stored in scheduled_at against datetime('now')'s
* "YYYY-MM-DD HH:MM:SS" format. On Postgres, casts to timestamptz.
*/
async findReadyToPublish(type: string): Promise<ContentItem[]> {
const tableName = getTableName(type);
const now = new Date().toISOString();

const isPostgresDialect = this.db.getExecutor().adapter.constructor.name === "PostgresAdapter";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This duplicates the dialect-detection logic that already lives in dialect-helpers.ts. Using the shared isPostgres(this.db) (or isSqlite(this.db)) helper is more maintainable and consistent with loader.ts, snapshot.ts, and the rest of the codebase.

const scheduledAtExpr = isPostgresDialect
? sql`scheduled_at::timestamptz`
: sql`datetime(scheduled_at)`;
const nowExpr = isPostgresDialect ? sql`CURRENT_TIMESTAMP` : sql`datetime('now')`;

const result = await sql<Record<string, unknown>>`
SELECT * FROM ${sql.ref(tableName)}
WHERE scheduled_at IS NOT NULL
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] findReadyToPublish returns any content with scheduled_at in the past, regardless of status. The read-time workaround in buildStatusCondition only considers status = 'published' or status = 'scheduled' as effectively publishable. If a post ever has a different status with a lingering scheduled_at, the auto-publisher would unexpectedly republish it. Consider constraining the query to match the read-time behavior:

Suggested change
WHERE scheduled_at IS NOT NULL
const result = await sql<Record<string, unknown>>`
SELECT * FROM ${sql.ref(tableName)}
WHERE scheduled_at IS NOT NULL
AND status IN ('scheduled', 'published')
AND ${scheduledAtExpr} <= ${nowExpr}
AND deleted_at IS NULL
ORDER BY scheduled_at ASC
`.execute(this.db);

AND scheduled_at <= ${now}
AND ${scheduledAtExpr} <= ${nowExpr}
AND deleted_at IS NULL
ORDER BY scheduled_at ASC
`.execute(this.db);
Expand Down
15 changes: 12 additions & 3 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ function isValidMetadataContribution(c: unknown): c is PageMetadataContribution

import { after } from "./after.js";
import { loadBundleFromR2 } from "./api/handlers/marketplace.js";
import { runSystemCleanup } from "./cleanup.js";
import { publishScheduledContent, runSystemCleanup } from "./cleanup.js";
import {
DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
defaultCommentModerate,
Expand Down Expand Up @@ -1165,8 +1165,9 @@ export class EmDashRuntime {
cronScheduler = new NodeCronScheduler(cronExecutor);
}

// Register system cleanup to run alongside each scheduler tick.
// Pass storage so cleanupPendingUploads can delete orphaned files.
// Register system cleanup and scheduled publishing to run
// alongside each scheduler tick. Both are independent and
// non-fatal -- failures are logged internally.
cronScheduler.setSystemCleanup(async () => {
try {
await runSystemCleanup(db, storage ?? undefined);
Expand All @@ -1175,6 +1176,14 @@ export class EmDashRuntime {
// by runSystemCleanup. This catches unexpected errors.
console.error("[cleanup] System cleanup failed:", error);
}

try {
await publishScheduledContent(db);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] On Cloudflare Workers, publishScheduledContent runs twice per request that goes through doInit():

  1. Via tickCron()PiggybackScheduler.onRequest()systemCleanup() (fire-and-forget, no waitUntil).
  2. Via the middleware after() path (with waitUntil).

The PR description itself notes that the piggyback scheduler fires promises without waitUntil, so Cloudflare can kill them before completion. Running the same routine twice is wasteful and risks concurrent publish attempts on the same items. Since the middleware after() path is strictly more reliable on Workers, skip the cron-scheduler publish path when the scheduler is a PiggybackScheduler:

Suggested change
await publishScheduledContent(db);
// Only run scheduled publishing via cron on Node; on Workers the
// middleware after() path is more reliable (has waitUntil).
if (!(cronScheduler instanceof PiggybackScheduler)) {
try {
await publishScheduledContent(db);
} catch (error) {
// Non-fatal -- individual publish failures are already logged
// by publishScheduledContent. This catches unexpected errors.
console.error("[scheduled] Scheduled content publishing failed:", error);
}
}

} catch (error) {
// Non-fatal -- individual publish failures are already logged
// by publishScheduledContent. This catches unexpected errors.
console.error("[scheduled] Scheduled content publishing failed:", error);
}
});

// Add cron reschedule callback (merges with existing factory options)
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,11 +306,14 @@ function buildStatusCondition(

if (status === "published") {
// Include both published content AND scheduled content past its publish time.
// scheduled_at is stored as text (ISO 8601). On Postgres, we must cast it
// to timestamptz for the comparison with CURRENT_TIMESTAMP to work.
// scheduled_at is stored as ISO 8601 text (e.g. "2026-05-05T01:41:59.000Z").
// On Postgres, cast to timestamptz for proper comparison.
// On SQLite, wrap both sides in datetime() to normalize format —
// scheduled_at uses "T" and "Z" separators while datetime('now')
// returns "YYYY-MM-DD HH:MM:SS", so a raw text comparison fails.
const scheduledAtExpr = isPostgres(db)
? sql`${sql.ref(scheduledAtField)}::timestamptz`
: sql.ref(scheduledAtField);
: sql`datetime(${sql.ref(scheduledAtField)})`;
return sql`(${sql.ref(statusField)} = 'published' OR (${sql.ref(statusField)} = 'scheduled' AND ${scheduledAtExpr} <= ${currentTimestampValue(db)}))`;
}

Expand Down
Loading
Loading