fix(core): auto-publish scheduled content and fix SQLite datetime comparison#1216
fix(core): auto-publish scheduled content and fix SQLite datetime comparison#1216scottbuscemi wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: 57f7a6c The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
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 |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 57f7a6c | May 29 2026, 08:41 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 57f7a6c | May 29 2026, 08:41 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | 57f7a6c | May 29 2026, 08:40 PM |
bf8f00b to
5fc2f12
Compare
…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
5fc2f12 to
57f7a6c
Compare
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
There was a problem hiding this comment.
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:
findReadyToPublishqueries content regardless of status. The read-time workaround inbuildStatusConditiononly treatspublishedandscheduledas publishable. Adding that constraint would prevent unexpected republishing if an edge-case status ever has a lingeringscheduled_at.findReadyToPublishuses inline adapter-name inspection instead of the sharedisPostgres()/isSqlite()helpers indialect-helpers.ts.- The "continues publishing when one fails" test doesn't actually trigger a failure:
findReadyToPublishfiltersdeleted_at IS NULL, so the deleted item is excluded beforepublish()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); |
There was a problem hiding this comment.
[needs fixing] On Cloudflare Workers, publishScheduledContent runs twice per request that goes through doInit():
- Via
tickCron()→PiggybackScheduler.onRequest()→systemCleanup()(fire-and-forget, nowaitUntil). - Via the middleware
after()path (withwaitUntil).
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:
| 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 |
There was a problem hiding this comment.
[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:
| 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"; |
There was a problem hiding this comment.
[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 () => { |
There was a problem hiding this comment.
[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).
What does this PR do?
Fixes scheduled posts never becoming published. Three independent bugs:
No auto-publish mechanism --
ContentRepository.findReadyToPublish()existed but was never called outside tests (dead code). The system relied on a read-time workaround inbuildStatusCondition()that treated past-due scheduled content as "effectively published" at query time, but the database status never transitioned fromscheduledtopublished.SQLite format mismatch --
scheduled_atis stored as ISO 8601 withTandZ(e.g.2026-05-05T01:41:59.000Z) but SQLite'sdatetime('now')returns2026-05-05 01:43:15. On same-day comparisons,T(0x54) > space (0x20), soscheduled_at <= datetime('now')was always false on SQLite/D1.Wrong execution path for the publish check -- The initial approach wired
publishScheduledContentinto the cron system'ssystemCleanupcallback. This failed for two reasons:NodeCronScheduleronly fires based on plugin cron tasks. With no plugins registered, the timer polls atMAX_INTERVAL_MS(5 minutes) -- far too slow for timely publishing.PiggybackSchedulerfires promises withoutwaitUntil(), so Cloudflare can kill the DB queries before they complete after the response is sent.Closes #917
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
Changes
middleware.ts-- AddedpublishScheduledContentcall directly in thedoInit()middleware path usingafter()with 15-second debounce.after()properly extends Worker lifetime viawaitUntilon Cloudflare, so the DB queries survive past the response. Capturesruntime.db(ALS-aware) before enteringafter()so playground/DO sessions get the correct database.cleanup.ts-- AddedpublishScheduledContent()that iterates all collections, finds items wherescheduled_at <= now, and publishes each viaContentRepository.publish()(revision promotion, data sync, schedule clearing, atomic per-item).emdash-runtime.ts-- Also wiredpublishScheduledContentinto the cron scheduler tick as a backup path (runs alongsiderunSystemCleanup). CalledtickCron()from middleware so the piggyback scheduler fires on Workers.loader.ts-- FixedbuildStatusCondition()to wrapscheduled_atindatetime()on SQLite, normalizing ISO 8601 T/Z format for comparison withdatetime('now').content.ts-- FixedfindReadyToPublish()to use DB-sidedatetime()comparison instead of JStoISOString()for cross-dialect correctness.snapshot.ts-- Replaced hardcodeddatetime('now')with dialect-awarecurrentTimestampValue(db)and addeddatetime()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:
scheduledtopublishedTest output
11 new tests:
publishScheduledContent: scheduled draft, published with draft changes, future dates, multiple collections, idempotency, ISO 8601 formats, error resiliencescheduled_atand SQLiteCURRENT_TIMESTAMPalways evaluates false #917 onfindReadyToPublishwith ISO 8601 T/Z formatscheduled_atand SQLiteCURRENT_TIMESTAMPalways evaluates false #917 SQLite format comparison: raw comparison proves the bug (returns false on same-day times),datetime()comparison proves the fix (returns true)Relation to PR #772
PR #772 adds an external
POST /_emdash/api/cron/publish-dueendpoint. This PR is complementary -- it makes scheduling work out of the box without requiring external cron configuration.