Skip to content

fix(publish): republish the site when an entry's public state changes - #3

Draft
asachs01 wants to merge 1 commit into
wyre-releasefrom
fix/republish-site-when-entry-publish-state-changes
Draft

fix(publish): republish the site when an entry's public state changes#3
asachs01 wants to merge 1 commit into
wyre-releasefrom
fix/republish-site-when-entry-publish-state-changes

Conversation

@asachs01

@asachs01 asachs01 commented Aug 17, 2026

Copy link
Copy Markdown

Summary

Published listing pages drift out of step with their entries, and this fixes it.

A listing is a static artefact like any other page: its base.loop over a content table is expanded once, at full-publish time, and the resulting cards are baked into the slot. publishDataRow rewrites the entry's own artefact and nothing else — it never re-expands anybody else's loop. So the moment an entry enters or leaves public visibility, every index that links to it is wrong and stays wrong until a human presses Publish.

Both halves of that were hit in production on blog-cms.wyretechnology.com:

  • a deleted post kept a live card on /blog while its own URL 404'd;
  • a post that auto-published at 09:00 was absent from /blog for hours. publishScheduler.ts calls publishDataRow to flip status and does nothing else.

Three more posts are scheduled (2026-09-07, 2026-09-14, 2026-10-12) and would each have hit the same thing.

This cannot be fixed from a plugin. content.entry.* hooks exist, but the only publish-ish RPC target is cms.content.republishAll, and republishSinglePage re-renders an existing snapshot and discards the HTML — its documented purpose is firing hook side-effects. It cannot see new content and cannot rewrite a listing. There is no cms.site.publish target.

What changed

Publish, scheduled publish, unpublish, and delete now call requestAutoSitePublish(db, uploadsDir) (new server/publish/autoSitePublish.ts), which runs one background publishDraftSite — the only thing that re-expands a loop.

Rule How
Coalesced First request opens a 5s batch window; everything inside it is absorbed. Forty posts cost one publish. Half a publishScheduler tick, so one tick's due rows land in one batch.
Never re-entrant At most one run in flight — two would race the slot swap. Requests during a run collapse into exactly one follow-up.
Never recursive Structural, plus an architecture gate. A runtime origin check would lie: plugin publish.* handlers run in the QuickJS worker and their RPCs return on their own event-loop task.
Never a surprise publish publishDraftSite promotes the draft, so a run proceeds only while the draft already matches what is published — it then re-expands the loops and changes nothing else. With unpublished site edits present it is skipped and logged.

Background, so the author's request returns as soon as their entry commits. A failed run is logged and dropped: the bake reaches swapSlot only on success, so the live site is untouched. Attribution is the system actor (null), the convention the scheduled-publish tick already uses — hence publishDraftSite now takes string | null.

AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE=0 (also false/off/no) restores the old behaviour. Default on.

Verification

  • bun run build
  • bun test — 6627 pass / 1 skip / 0 fail (baseline on wyre-release: 6616 / 1 / 0; +11 new)
  • bun run lint
  • Docker/deployment check — compose.prod.yml passes the new variable through; not run against a live stack

Checklist

  • Tests cover behavior changes — scheduler path, coalescing (N entries to 1 publish, including requests raised mid-run), the recursion guard, the disabled-by-config case, the draft-drift guard. All against a real SQLite DB through the real repositories; nothing mocked.
  • Docs updated — docs/features/publisher.md, docs/server.md, docs/features/content-storage.md, docs/reference/architecture-tests.md, docs/deployment/README.md, .env.example, .env.production.example, CHANGELOG.md.
  • No compatibility shim was added for old pre-release behavior.
  • No secrets, local databases, uploads, or generated artifacts are included.

Notes for reviewers

  • The draft-drift guard is the load-bearing design decision. Without it, publishing a blog post would promote whatever is sitting in the site draft, which is exactly what the step-up-gated Publish button exists to prevent. With it, an automatic run is provably a pure re-bake. Cost: while an operator has unpublished site edits, listings stay stale — same as today — and the run logs why.
  • Cost of leaving it on: each automatic run writes a site_snapshots row plus one data_row_versions row per page, the same as a manual Publish.
  • Not wired here: the plugin-host paths (handleContentEntriesPublish / Delete / DeleteMany) have no uploadsDir and already skip artefact writes and prunes. That is a pre-existing gap and a separate change.

An upstream PR against corebunch/instatic carries the same commit.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

A listing page is a static artefact: its `base.loop` over a content table
is expanded once, at full-publish time, and baked into the slot. Per-entry
publishing rewrites that entry's own artefact and nothing else, so the
moment an entry enters or leaves public visibility every index that links
to it is wrong, and stays wrong until a human presses Publish. A deleted
post keeps a live card pointing at a 404; a post scheduled for 09:00 is
missing from the index until someone notices.

Publish, scheduled publish, unpublish, and delete now ask
`server/publish/autoSitePublish.ts` for a background full-site republish,
which is the only thing that re-expands a loop. Four rules make that
affordable and safe:

- Coalesced: the first request opens a 5s batch window and every request
  inside it is absorbed, so a backlog of forty posts costs one publish.
  The window is half a `publishScheduler` tick, so one tick's due rows
  land in a single batch.
- Never re-entrant: at most one run is in flight, because two would race
  the slot swap. Requests raised during a run collapse into exactly one
  follow-up window.
- Never recursive: guaranteed structurally, with an architecture test that
  fails the build if a new caller appears. A runtime origin check would
  lie — plugin publish.* handlers run in the QuickJS worker and their RPCs
  return on their own event-loop task.
- Never a surprise publish: `publishDraftSite` promotes the draft, so a run
  proceeds only while the draft already matches what is published. It then
  changes no page and re-expands the loops and nothing else. With
  unpublished site edits present the run is skipped and logged.

The rebuild is background work, so an author's request returns as soon as
their entry commits. A failed run is logged and dropped: the entry publish
already committed and the bake reaches `swapSlot` only on success, so the
live site is untouched. Attribution is the system actor, the convention the
scheduled-publish tick already uses — nobody asked for this site publish,
which is also why `publishDraftSite` now takes `string | null`.

`AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE=0` (also false/off/no) restores the old
behaviour for operators who publish on their own cadence. Default is on:
the stale listing is a correctness bug, not a preference.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant