Skip to content

fix(core): auto-publish scheduled content and fix SQLite datetime comparison#1216

Open
scottbuscemi wants to merge 1 commit into
mainfrom
fix/scheduled-publishing-917
Open

fix(core): auto-publish scheduled content and fix SQLite datetime comparison#1216
scottbuscemi wants to merge 1 commit into
mainfrom
fix/scheduled-publishing-917

Conversation

@scottbuscemi
Copy link
Copy Markdown
Collaborator

@scottbuscemi scottbuscemi commented May 29, 2026

What does this PR do?

Fixes scheduled posts never becoming published. Three independent bugs:

  1. No auto-publish mechanism -- ContentRepository.findReadyToPublish() existed but was never called outside tests (dead code). The system relied on a read-time workaround in buildStatusCondition() that treated past-due scheduled content as "effectively published" at query time, but the database status never transitioned from scheduled to published.

  2. SQLite format mismatch -- 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 2026-05-05 01:43:15. On same-day comparisons, T (0x54) > space (0x20), so scheduled_at <= datetime('now') was always false on SQLite/D1.

  3. Wrong execution path for the publish check -- The initial approach wired publishScheduledContent into the cron system's systemCleanup callback. This failed for two reasons:

    • On Node, the NodeCronScheduler only fires based on plugin cron tasks. With no plugins registered, the timer polls at MAX_INTERVAL_MS (5 minutes) -- far too slow for timely publishing.
    • On Workers, the PiggybackScheduler fires promises without waitUntil(), so Cloudflare can kill the DB queries before they complete after the response is sent.

Closes #917

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4 via OpenCode

Screenshots / test output

Changes

middleware.ts -- Added publishScheduledContent call directly in the doInit() middleware path using after() with 15-second debounce. after() properly extends Worker lifetime via waitUntil on Cloudflare, so the DB queries survive past the response. Captures runtime.db (ALS-aware) before entering after() so playground/DO sessions get the correct database.

cleanup.ts -- Added publishScheduledContent() that iterates all collections, finds items where scheduled_at <= now, and publishes each via ContentRepository.publish() (revision promotion, data sync, schedule clearing, atomic per-item).

emdash-runtime.ts -- Also wired publishScheduledContent into the cron scheduler tick as a backup path (runs alongside runSystemCleanup). Called tickCron() from middleware so the piggyback scheduler fires on Workers.

loader.ts -- Fixed buildStatusCondition() to wrap scheduled_at in datetime() on SQLite, normalizing ISO 8601 T/Z format for comparison with datetime('now').

content.ts -- Fixed findReadyToPublish() to use DB-side datetime() comparison instead of JS toISOString() for cross-dialect correctness.

snapshot.ts -- Replaced hardcoded datetime('now') with dialect-aware currentTimestampValue(db) and added datetime() wrapping for SQLite.

Verified end-to-end

Scheduled posts auto-publish within 15 seconds of their scheduled time on the next request. Tested on local Node dev server by:

  1. Creating a draft post
  2. Scheduling it 5 seconds in the future
  3. Waiting for the time to pass
  4. Making an authenticated request
  5. Confirming the post status flipped from scheduled to published

Test output

 Test Files  221 passed (221)
      Tests  3493 passed (3493)

11 new tests:

Relation to PR #772

PR #772 adds an external POST /_emdash/api/cron/publish-due endpoint. This PR is complementary -- it makes scheduling work out of the box without requiring external cron configuration.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: 57f7a6c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 29, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1216

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1216

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1216

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1216

emdash

npm i https://pkg.pr.new/emdash@1216

create-emdash

npm i https://pkg.pr.new/create-emdash@1216

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1216

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1216

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1216

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1216

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1216

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1216

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1216

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1216

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1216

commit: 57f7a6c

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 57f7a6c May 29 2026, 08:41 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 57f7a6c May 29 2026, 08:41 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs 57f7a6c May 29 2026, 08:40 PM

@scottbuscemi scottbuscemi force-pushed the fix/scheduled-publishing-917 branch from bf8f00b to 5fc2f12 Compare May 29, 2026 20:05
…parison

Two independent bugs prevented scheduled posts from ever becoming published:

1. No auto-publish mechanism existed — findReadyToPublish() was dead code,
   never called outside tests. Added publishScheduledContent() and wired it
   into middleware via after() with 15 s debounce. Uses after() to extend
   Worker lifetime via waitUntil on Cloudflare. Also kept as a backup in
   the cron system cleanup tick.

2. SQLite format mismatch (#917) — scheduled_at stores ISO 8601 with T/Z
   separators but datetime('now') returns space-separated format. The
   lexicographic comparison always returned false. Fixed by wrapping both
   sides in datetime() on SQLite in loader.ts, content.ts, and snapshot.ts.

Verified end-to-end: scheduled posts auto-publish within 15 seconds of
their scheduled time on the next request.

Fixes #917
@scottbuscemi scottbuscemi force-pushed the fix/scheduled-publishing-917 branch from 5fc2f12 to 57f7a6c Compare May 29, 2026 20:38
@github-actions
Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label May 30, 2026
Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Choose a reason for hiding this comment

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

This PR fixes three real, well-identified bugs around scheduled publishing: dead code (findReadyToPublish was never called), SQLite ISO-8601 vs datetime('now') format mismatch (#917), and unreliable execution paths on Workers/Node. The approach — running publishScheduledContent via middleware after() with debounce — is the right pattern for Cloudflare Workers, and the SQLite datetime() normalization is correct. The regression tests for the format bug are solid.

Main issue: On Cloudflare Workers, publishScheduledContent runs twice per doInit() request. tickCron() triggers PiggybackScheduler.onRequest(), which fires systemCleanup() (including publish) without waitUntil. Then the middleware separately calls after(publishScheduledContent(db)), which has waitUntil. The PR itself calls the piggyback path unreliable on Workers because it lacks waitUntil. Running the same routine twice is wasteful and risks concurrent publish attempts. The fix is to skip the cron-scheduler publish path when the scheduler is a PiggybackScheduler.

Other observations:

  • findReadyToPublish queries content regardless of status. The read-time workaround in buildStatusCondition only treats published and scheduled as publishable. Adding that constraint would prevent unexpected republishing if an edge-case status ever has a lingering scheduled_at.
  • findReadyToPublish uses inline adapter-name inspection instead of the shared isPostgres() / isSqlite() helpers in dialect-helpers.ts.
  • The "continues publishing when one fails" test doesn't actually trigger a failure: findReadyToPublish filters deleted_at IS NULL, so the deleted item is excluded before publish() is ever called.

Overall the implementation is clean and the fixes are correct. Tightening up the Worker double-publish and the two minor nits would make it ready to merge.

}

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


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

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.

expect(updated?.scheduledAt).toBeNull();
});

it("continues publishing other items when one fails", async () => {
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 test claims to verify failure resilience, but findReadyToPublish filters deleted_at IS NULL, so the deleted post1 is excluded from the results and publish() never sees it to fail. The test passes because post2 is published, but it doesn't actually exercise the error-handling path in publishScheduledContent. Consider either renaming the test to match what it verifies (e.g., "ignores soft-deleted scheduled items") or using a strategy that causes publish() to throw on a non-deleted item (such as mocking ContentRepository.publish).

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond needs-rebase and removed review/needs-review No maintainer or bot review yet labels May 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core bot:review Trigger an emdashbot code review on this PR cla: signed needs-rebase overlap review/awaiting-author Reviewed; waiting on the author to respond size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scheduled posts never become visible on SQLite/D1: string comparison between ISO scheduled_at and SQLite CURRENT_TIMESTAMP always evaluates false

2 participants