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";
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
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);
} 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