Blog system: content, publishing, and scheduling
Refract’s blog stores articles in Postgres, serves MDX from the CDN, and exposes public GraphQL to the marketing site. Scheduled publishes use a reconciliation controller plus a delayed queue — the database is always the source of truth.How the blog is built
Editor markdown syntax lives on Blog article markdown cheatsheet.
Article and media CDN lifecycle
Article MDX and blog media are public, cacheable objects. We version by object key — not in-place overwrite — so browsers and edge CDNs can cache aggressively without serving stale content after an edit.
Why two ops per article save: overwriting the same URL/key would be one
PutObject, but anything already cached at the old URL could keep serving old MDX until TTL expiry or an explicit purge. A new key + DB pointer update gives readers a fresh URL; deleting the previous key limits orphan storage.
Billing implication: on Cloudflare R2 (and similar object stores), PutObject and DeleteObject are billed as mutating request classes — not storage size alone. Heavy editorial churn (many Saves on long drafts) increases request cost linearly with saves, even when byte size is small. Explicit Save in the CMS reduces accidental churn from autosave; it does not change the per-save cost when you do save.
Orphan objects can remain under articles/ or blog-media/ if a save failed mid-flight, a row was deleted without the cleanup path, or code predated delete-on-replace. The database (content_path, blog_media.cdn_object_key) is the source of truth for what is live.
See CDN overview — object lifecycle and Cloudflare R2 — blog operations.
Scheduling and publishing
These contracts are intentional — not missing functionality.Scheduling is intentionally bounded. The queue is a rolling execution buffer; the database holds long-term schedule state.
DLQ is not part of the recovery path. Failed publish jobs are observable in the DLQ for debugging only. Recovery is via reconciliation controllers and the CMS reschedule hook.
- Queue:
BLOG_ARTICLE_PUBLISH,jobId = blog-publish-{articleId}(no:— BullMQ constraint),replace: trueon enqueue - Cancel:
removeQueuedJob— skips active jobs (skipped_active_cancel, no throw) - Consumer →
publishBlogArticleatomicUPDATE(status,scheduled_at,archived_at) - Discovery regen on every visibility change; 6h
blogRegenerateDiscoverycron is backstop only
Monitoring and alerting
Reconcile and publish paths emit low-cardinality metrics fromapps/backend/src/utilities/blog/metrics.ts. Wire dashboards or alerts on these counters — not on per-article IDs.
Runbook (stuck scheduled article):
- Confirm row in
blog_articles:status = scheduled,scheduled_atin the past,archived_atnull. - Check consumer logs for
blogArticlePublishand metricblog.publish.consumer. - Trigger reconcile: wait for daily
blogPublishScheduled/ weeklyblogPublishReconcile, or reschedule from the CMS publish drawer (re-enqueues viapublishEnqueue). - If enqueue keeps failing, inspect queue DLQ for payload debugging only — recovery is reconcile + CMS reschedule, not DLQ replay.
Operator mental model
- Debug DB first (
status,scheduled_at,archived_at) - The queue is not schedule storage
- Watch for stuck
scheduledrows pastscheduled_at, growing queue depth, or repeatedblogRegenerateDiscoverywarn logs
Extending this system
- Add business logic in
apps/backend/src/utilities/blog/ - Register queue name + DLQ in
apps/backend/src/utilities/queue.tsand config (development.ts,staging.ts,production.ts) - Add consumer in
apps/backend/src/tools/queue/consumers/and register inconsumerRegistry.ts - Add schedule name in
scheduleName.ts, handler inschedules/, and register inscheduleRegistry.ts - Write tests under
apps/backend/src/utilities/blog/__tests__/ - Update this page when behavior changes
What not to do
❌ Never publish inline in scheduler handlers — call reconcileBlogPublishJobs only.
❌ Never use the queue as the source of truth for schedules.
❌ Never replay DLQ messages for normal recovery.
❌ Never callsendToQueuefrom GraphQL — useenqueueBlogArticlePublish/cancelBlogArticlePublishJob.
File conventions
What’s next?
- Blog article markdown cheatsheet — editor syntax
- CDN overview — object lifecycle and no in-place overwrite
- Cloudflare R2 — blog write ops and billing
- Scheduler overview — cron and test client
- Marketing site — public blog rendering
