Skip to content

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

Draft
asachs01 wants to merge 1 commit into
CoreBunch:mainfrom
wyre-technology:upstream/fix/republish-site-on-entry-publish
Draft

fix(publish): republish the site when an entry's public state changes#397
asachs01 wants to merge 1 commit into
CoreBunch:mainfrom
wyre-technology:upstream/fix/republish-site-on-entry-publish

Conversation

@asachs01

Copy link
Copy Markdown

Summary

A published listing page never learns that its entries changed.

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 disagrees with reality, and keeps disagreeing until a human presses Publish.

We hit both halves of that on a production install this week:

  1. An author deleted a post. Its own URL started returning 404 immediately — correct, handleRowItemDelete prunes the artefact — but its card stayed on /blog, linking readers to the 404.
  2. A post scheduled for 09:00 published on time and was absent from /blog for hours. publishScheduler.ts calls publishDataRow to flip status and does nothing else, so the index was never rebuilt. Three more posts were scheduled behind it, each queued up to do the same.

Neither is a misconfiguration. The listing is simply frozen at whatever the last full publish saw.

Why this can't live in a plugin

We tried that route first. The content.entry.created / .updated / .deleted hooks in src/core/plugin-sdk/types/hooks.ts fire at the right moments, but there is nothing a handler can call to fix the listing:

  • The only publish-ish RPC target is cms.content.republishAll (server/plugins/protocol/targets.ts).
  • republishSinglePage (server/publish/republish.ts) reads the existing snapshot via getPublishedPageSnapshotById, re-renders it, and discards the HTML. Its documented purpose is firing hook side-effects, not writing artefacts. It cannot see content published after that snapshot, and it cannot rewrite a listing.
  • There is no cms.site.publish target.

The gap is in the CMS, so the fix is too.

The change

New server/publish/autoSitePublish.ts. The four paths that change an entry's public visibility — publish, scheduled publish, unpublish, delete — call requestAutoSitePublish(db, uploadsDir) next to the emitContentEntry* call each already makes, and the module runs one background publishDraftSite, which is the only thing that re-expands a loop.

Everything else in the module exists to make "a full publish per entry change" affordable and safe.

Coalescing

The first request opens a 5-second batch window; every request inside it is absorbed. Draining a backlog of forty posts costs one site publish, not forty. Same shape as the collab relay's schedulePersist — arm once, ignore while armed.

Five seconds is bounded on both sides. The floor: long enough to swallow a burst — a human working down a publish queue, an agent looping over a backlog, or one tickPublishScheduler pass firing up to TICK_BATCH_LIMIT rows in sequence. The ceiling: the scheduler polls every 10s, so a wider window would let a 09:00 post stay off the listing longer than it took to notice it was due. It is a fixed window from the first request rather than a resetting debounce, because a resetting debounce can be held open indefinitely by a steady trickle of publishes.

Re-entrancy

A site publish swaps the static slot, so at most one run is ever in flight. Requests raised during a run collapse into exactly one follow-up window — they may describe an entry the running publish read the database too early to see. publishDraftSite's own withPublishLock is untouched and still serializes against manual publishes.

Recursion

A site publish must not be able to trigger a site publish. That is guaranteed structurally, and pinned by src/__tests__/architecture/auto-site-publish-callers.test.ts: only the named visibility-change call sites may reference requestAutoSitePublish, and publishSite.ts may not import it.

We deliberately did not add a runtime origin check. Plugin publish.before / publish.html / publish.after handlers run in the QuickJS worker and their RPCs come back on their own event-loop task, so an AsyncLocalStorage-style flag would report "not inside a publish" for the one caller that could actually recurse. A guard that lies is worse than no guard; the gate is honest about being structural.

Never a surprise publish

This is the load-bearing decision and the one worth arguing about.

publishDraftSite promotes the draft site. If an operator is mid-redesign, promoting it because an author published a blog post would push unfinished work live — exactly the leak that the explicit, step-up-gated Publish button and site_publish's "publishing stays a separate operation" contract exist to prevent.

So a run proceeds only while getDraftPublishStatus reports the draft already matches what is published. In that state the run changes no page at all: its only effect is re-expanding the loops against current content. When the draft has unpublished edits the run is skipped and logged — and that operator is about to publish anyway, which fixes the listing.

The cost is stated plainly: while site edits are pending, listings stay as stale as they are today.

Background and failure behaviour

The author's request returns as soon as their entry commits; the rebuild happens on the timer. A failed run is logged under [publish:auto] and dropped — the entry publish already committed, and the bake reaches swapSlot only after it succeeds, so a failure leaves the live site byte-for-byte as it was and the entry in the state the author asked for.

Attribution is the system actor (published_by_user_id = null), the convention publishDataRow's scheduled-publish path already uses: nobody asked for this site publish, and blaming it on whoever happened to publish a post would be a lie. That is why publishDraftSite's adminUserId widens to string | null, matching publishDataRow.

Configuration

AUTO_SITE_PUBLISH_ON_ENTRY_CHANGEdefault on, because a listing that contradicts its entries is a correctness bug rather than a preference. Operators who publish on their own cadence set 0 / false / off / no. Only an explicit off-token disables; an unset, empty, or unrecognised value keeps the default rather than silently turning a correctness fix off because of a typo.

It is read by the module that owns the feature, the way renderCache.ts reads RENDER_CACHE_MAX_ENTRIES, rather than being threaded through server/config.ts to three unrelated call sites. compose.prod.yml passes it through.

Test coverage

src/__tests__/server/autoSitePublish.test.ts — 7 tests, all against a real SQLite database through the real repositories. Nothing is mocked, so a run that claims to publish has actually written a snapshot. Assertions count rows in site_snapshots, one per full publish.

  • the scheduled-publish tick asks for a republish, and the tick itself returns before the rebuild happens
  • a burst of 40 requests inside one window plus 40 more raised mid-run produce exactly 2 publishes, not 80
  • the recursion guard: a completed run leaves no work behind, so draining again publishes nothing
  • disabled by config publishes nothing
  • the switch reads as on unless explicitly turned off (including an unrecognised value)
  • a draft with unpublished edits is never promoted
  • a server with no static slot ignores the request

src/__tests__/architecture/auto-site-publish-callers.test.ts — 4 gates on the caller set and the structural non-recursion rule.

The coalescing assertion was mutation-tested: giving each deferred request its own run turns the expected 2 into 11, so the test genuinely bites rather than passing by construction.

Verification

bun test    6627 pass / 0 fail / 139878 expect() calls, 722 files   (main baseline: 6616 pass / 0 fail, 720 files)
bun run lint    clean
bun run build   tsc -b + vite build, ✓ built in 12.02s

Checklist

  • Tests cover behavior changes.
  • Docs updated — docs/features/publisher.md (new "Keeping listings honest" section), 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.

Known costs and things left alone

  • Storage. Each automatic run writes a site_snapshots row plus one data_row_versions row per page — the same amplification a manual Publish causes, now happening more often. Worth knowing before enabling on a high-churn install.
  • The plugin-host paths are not wired. handleContentEntriesPublish / handleContentEntriesDelete / handleContentEntriesDeleteMany have no uploadsDir and already skip artefact writes and prunes entirely, so wiring them here would have meant fixing a second, unrelated bug in this PR. Left for a separate change.

Happy to adjust the batch window, the flag name, or the draft-drift policy if you'd rather these behave differently.

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