Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ PORT=3001
UPLOADS_DIR=./uploads
STATIC_DIR=./dist

# ─── Publishing ──────────────────────────────────────────────────────────────
# Publishing, unpublishing, or deleting a content entry triggers a coalesced
# background site republish, so baked listing pages (a /blog index and the like)
# stop showing the set from before the change. On by default. Set to
# 0 / false / off / no if you publish the site on your own cadence.
#
# AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE=0

# ─── AI credential encryption ───────────────────────────────────────────────
# Local dev auto-creates .tmp/secret.key. Production deployments must set
# INSTATIC_SECRET_KEY to the output of:
Expand Down
8 changes: 8 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ HOST_PORT=3001
# networks or a non-Docker reverse proxy.
TRUSTED_PROXY_CIDRS=

# ─── Publishing ──────────────────────────────────────────────────────────────
# Publishing, unpublishing, or deleting a content entry triggers a coalesced
# background site republish, so baked listing pages (a /blog index and the like)
# stop showing the set from before the change. On by default — leave this empty
# unless you publish the site on your own cadence, in which case set it to
# 0 / false / off / no.
AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE=

# ─── Database — Postgres mode only ───────────────────────────────────────────
# These three are consumed by the postgres service and embedded in DATABASE_URL.
# REQUIRED for Postgres deployments — set POSTGRES_PASSWORD to a real secret.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ This project is pre-1.0. Breaking changes may appear in minor or patch releases

## Unreleased

### Content and publishing

- Kept published listing pages in step with their entries. A listing expands its loop at full-publish time and bakes the result, so publishing, scheduling, unpublishing, or deleting an entry used to leave every index that links to it showing the previous set — a deleted post kept a live card pointing at a 404, and a scheduled post stayed off the index until someone published by hand. Those four paths now trigger a background site republish, coalesced so a batch of entries costs one publish, and skipped while the site draft has unpublished edits so an entry publish never pushes unfinished design work live. Set `AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE=0` to publish on your own cadence instead.

## 0.0.16 - 2026-08-11

### Media and integrations
Expand Down
2 changes: 2 additions & 0 deletions compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ services:
STATIC_DIR: /app/dist
INSTATIC_SECRET_KEY: ${INSTATIC_SECRET_KEY:-}
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
# Empty means unset, which is the default: on.
AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE: ${AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE:-}
volumes:
- uploads:/app/uploads
depends_on:
Expand Down
3 changes: 2 additions & 1 deletion docs/deployment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This index maps supported deployment targets to the files, variables, and persistence rules they need.

Instatic is one Bun server packaged by the root `Dockerfile`. The server reads runtime configuration from `server/config.ts`: `PORT`, `DATABASE_URL`, `UPLOADS_DIR`, `STATIC_DIR`, `PUBLIC_ORIGIN`, and `TRUSTED_PROXY_CIDRS`. Reversible server secrets, including AI provider credentials, plugin secret settings, and MFA TOTP seeds, are encrypted with `INSTATIC_SECRET_KEY` when configured. Database migrations run automatically on boot in `server/index.ts`.
Instatic is one Bun server packaged by the root `Dockerfile`. The server reads runtime configuration from `server/config.ts`: `PORT`, `DATABASE_URL`, `UPLOADS_DIR`, `STATIC_DIR`, `PUBLIC_ORIGIN`, and `TRUSTED_PROXY_CIDRS`. Single-feature switches are read by the module that owns the feature rather than by `server/config.ts` — `AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE` in `server/publish/autoSitePublish.ts` is one; the Runtime Contract below lists every variable an operator sets, wherever it is read. Reversible server secrets, including AI provider credentials, plugin secret settings, and MFA TOTP seeds, are encrypted with `INSTATIC_SECRET_KEY` when configured. Database migrations run automatically on boot in `server/index.ts`.

---

Expand Down Expand Up @@ -32,6 +32,7 @@ STATIC_DIR built admin SPA directory; /app/dist in the Docker image
INSTATIC_SECRET_KEY base64 32-byte key for encrypted server secrets
PUBLIC_ORIGIN comma-separated public origin(s) the CSRF check trusts; auto-detected from RENDER_EXTERNAL_URL / RAILWAY_PUBLIC_DOMAIN on those platforms
TRUSTED_PROXY_CIDRS optional; trusts proxy socket peers for forwarded client-IP attribution only (audit logs, rate-limit keys) — NOT used for CSRF
AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE optional; default on. Set 0/false/off/no to stop publishing, unpublishing, or deleting a content entry from triggering a background site republish. Leave it on unless you publish on your own cadence — the republish is what keeps baked listing pages in step with their entries
```

Generate `INSTATIC_SECRET_KEY` with `bun run scripts/generate-secret-key.ts` before adding Anthropic, OpenAI, or OpenRouter credentials or enabling TOTP MFA in production. Without it, the admin can load but saving reversible secrets fails because there is no stable encryption key.
Expand Down
2 changes: 2 additions & 0 deletions docs/features/content-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ For **post-types**, public row routes require an explicitly authored entry templ

`status: 'scheduled'` with `scheduled_publish_at: <ISO datetime>`. The publisher's scheduler tick (`server/publish/publishScheduler.ts`) polls for rows where `scheduled_publish_at <= now()`, fires `publishDataRow(...)`, and flips the row to `published`. On failure, the row drops back to `draft`.

A row publish writes that row's own artefact and nothing else, so any baked listing page that loops over its table would still show the pre-publish set. Every path that changes an entry's public visibility — this tick, and the publish / unpublish / delete routes — therefore asks `server/publish/autoSitePublish.ts` for a coalesced background site republish. See [docs/features/publisher.md](publisher.md) → "Keeping listings honest".

---

## Cookbook
Expand Down
42 changes: 42 additions & 0 deletions docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ server/publish/
├── mediaPrefetch.ts, loopPrefetch.ts — pre-warm caches needed by the renderer
├── republish.ts — bulk re-publish on site-level changes
├── publishScheduler.ts — scheduled publish jobs
├── autoSitePublish.ts — coalesced site republish after an entry's public state changes
├── runtime/ — per-site bun install workspace serving
└── loopRuntime.ts — loop runtime asset
```
Expand Down Expand Up @@ -319,6 +320,46 @@ The exclusive namespaces `/_instatic/css/*` (`serveSiteCss`) and `/_instatic/ass
publish whose disk write failed. Unknown paths under either prefix 404 rather
than falling through.

### Keeping listings honest — automatic republish

Baking everything to disk has one consequence that has to be handled
explicitly: **a listing is a static artefact too**. A `base.loop` over a content
table is expanded once, at full-publish time, and the resulting cards are baked
into the slot. Per-entry publishing (`publishDataRow`) rewrites that 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 listing that
links to it is stale, and stays stale until someone presses Publish: a deleted
post keeps a live card pointing at a 404, and a scheduled post that fires at
09:00 is missing from the index until a human notices.

`server/publish/autoSitePublish.ts` closes that gap. Every path that changes an
entry's public visibility — publish, scheduled publish, unpublish, delete —
calls `requestAutoSitePublish(db, uploadsDir)` next to the `emitContentEntry*`
call it already makes, and the module runs one `publishDraftSite` in the
background. Four rules make that affordable and safe:

| Rule | How |
|---|---|
| **Coalesced** | The first request opens a 5-second batch window; every request inside it is absorbed. Forty posts going live cost one site publish. The window is half a `publishScheduler` tick, so one tick's worth of due rows lands in a single batch. |
| **Never re-entrant** | At most one run is in flight — two would race the slot swap. Requests raised during a run collapse into exactly one follow-up window (the running publish may have read the database before their entry committed). |
| **Never recursive** | Guaranteed structurally: the publish pipeline never calls the trigger, and `src/__tests__/architecture/auto-site-publish-callers.test.ts` 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*. A run therefore only proceeds while the draft already matches what is published, so it changes no page and its only effect is re-expanding the loops. With unpublished site edits present the run is skipped and logged — that operator is about to publish anyway. |

The rebuild is background work: the author's request returns as soon as their
entry is committed. 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 exactly as it was. Attribution is
the system actor (`published_by_user_id = null`), the same convention the
scheduled-publish tick uses: nobody asked for this site publish.

Operators who publish on their own cadence set
`AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE=0` (also `false` / `off` / `no`) and keep the
old behaviour, where only an explicit Publish rebuilds the site. Default is on.
Note the cost of leaving it on: each automatic run writes a new
`site_snapshots` row and a new `data_row_versions` row per page, exactly as a
manual Publish does.

---

## `<head>` assembly
Expand Down Expand Up @@ -377,6 +418,7 @@ Because `serializeCsp` sorts, the same plugins + adapters always emit a **byte-i
| `server/publish/moduleJsBundle.ts` | Module-JS channel: `buildSiteModuleJsMap` (fresh), `buildPublishedSiteModuleJsMap` (memoised per publishVersion + site, invalidated by `bumpPublishVersion()`), and `injectModuleScripts` (per-page `<script defer>` tags + CSP `script-src 'self'` relaxation). |
| `server/publish/republish.ts` | Bulk re-publish on settings change (touches every page). |
| `server/publish/publishScheduler.ts` | Scheduled publish jobs (cron-style). |
| `server/publish/autoSitePublish.ts` | `requestAutoSitePublish` — coalesced, non-re-entrant, background site republish after an entry's public visibility changes, so baked listings stop showing the pre-change set. See "Keeping listings honest". |
| `server/publish/frontendInjections.ts` | Compute plugin `<script>`/`<link>`/`<meta>` tags + CSP entries. |
| `server/publish/mediaPresentation.ts` | Materialize media paths (originals + responsive variants) for publisher consumers. |
| `src/core/publisher/responsiveBackground.ts` | Convert media-library `background-image: url(...)` values into optimized variant fallback + `image-set(...)` declarations. |
Expand Down
1 change: 1 addition & 0 deletions docs/reference/architecture-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ See [docs/features/media.md](../features/media.md).
| `publish-html-filter-context.test.ts` | Plugin `publish.html` filters receive the right context shape. |
| `static-artefact-served-before-render.test.ts`| `publicRouter.ts` calls `readArtefact` BEFORE `resolvePublicRoute`; the fast-path fires when `canonicalRenderQuery(url.searchParams) === ''` — junk params (UTM, etc.) collapse to `''` and serve the artefact, only render-affecting loop-pagination params (`loop_<nodeId>_page`) fall through to the live renderer. |
| `publish-bumps-cache-version.test.ts` | Every publish / unpublish entry point (`publishDraftSite`, `publishDataRow`, `updateDataRowStatus`) calls `bumpPublishVersion()` imported from `publishState.ts` so Layer B evicts on every state change visitors can see. |
| `auto-site-publish-callers.test.ts` | Only the named entry-visibility call sites (`handlers/cms/data/rows.ts`, `publishScheduler.ts`) call `requestAutoSitePublish`, and `publishSite.ts` never imports it — the structural guarantee that a site publish cannot trigger a site publish, and that a full publish is never attached to an ordinary draft save. |
| `hole-runtime-asset-route.test.ts` | The router registers `tryServeHoleRuntimeAsset` and `tryServeHole` BEFORE `tryServePublicRoute`. The `/_instatic/hole/*` namespace can never fall through to slug resolution. |
| `module-js-asset-route.test.ts` | The router registers `tryServeModuleJsAsset` BEFORE `tryServePublicRoute`, and wires it from `server/handlers/cms/moduleJs`, so `/_instatic/module-js/*` requests cannot be swallowed by public-slug resolution. |

Expand Down
1 change: 1 addition & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ Server-side publishing helpers live in `server/publish/`:
| `siteCssBundle.ts` | Per-site reset / framework / style CSS bundles (hashed filenames). |
| `republish.ts` | Bulk re-publish (after a settings change touches all pages). |
| `publishScheduler.ts` | Scheduled publish jobs. |
| `autoSitePublish.ts` | `requestAutoSitePublish` — coalesced background full-site republish after an entry's public visibility changes, so baked listing pages stop showing the pre-change set. Non-re-entrant; skipped while the site draft has unpublished edits; `AUTO_SITE_PUBLISH_ON_ENTRY_CHANGE=0` turns it off. |
| `frontendInjections.ts` | Plugin-contributed frontend scripts injected into published HTML. |
| `mediaPresentation.ts` | `<picture>` / `<img srcset>` materialization at publish time. |
| `mediaPrefetch.ts`, `loopPrefetch.ts` | Pre-warm caches needed by published pages. |
Expand Down
14 changes: 13 additions & 1 deletion server/handlers/cms/data/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
updateDataRowTable,
} from '../../../repositories/data'
import { publishDataRow, removeDataRowArtefact } from '../../../publish/publishRow'
import { requestAutoSitePublish } from '../../../publish/autoSitePublish'
import { runPublishFlush } from '../../../publish/publishFlush'
import { findUserById } from '../../../repositories/users'
import { slugForTable } from '@core/data/cells'
Expand Down Expand Up @@ -216,7 +217,12 @@ async function handleRowItemDelete(
}
// Layer B mirror of the artefact prune: a published row's route is
// retracted, so the render cache must stop serving it.
if (row.status === 'published') await bumpPublishVersionSerialized()
if (row.status === 'published') {
await bumpPublishVersionSerialized()
// Pruning the row's own artefact does not touch the listings that link to
// it — without a site republish they keep advertising a route that now 404s.
requestAutoSitePublish(db, options.uploadsDir)
}
await emitContentEntryDeleted(db, rowId, { kind: 'user', userId: user.id })
await recordRowAuditEvent(db, user, req, 'data.row.delete', row)
return jsonResponse({ row })
Expand All @@ -236,6 +242,9 @@ async function handleRowPublish(
if (currentRow instanceof Response) return currentRow

const result = await publishDataRow(db, rowId, user.id, options.uploadsDir)
// The row now has its own artefact, but every baked listing that loops over
// this table still shows the pre-publish set. Rebuild them in the background.
requestAutoSitePublish(db, options.uploadsDir)
await emitContentEntryUpdated(db, rowId, ['status'], { kind: 'user', userId: user.id })
await recordRowAuditEvent(db, user, req, 'data.row.publish', result.row, {
versionNumber: result.version.versionNumber,
Expand Down Expand Up @@ -333,6 +342,9 @@ async function handleRowStatus(
console.error('[publish:row] failed to remove artefact for retracted row', rowId, err)
})
}
// Only a row that WAS public changes what the listings should show; flipping
// an already-draft row between draft and unpublished is invisible publicly.
if (currentRow.status === 'published') requestAutoSitePublish(db, options.uploadsDir)
await recordRowAuditEvent(db, user, req, 'data.row.status', row, { status: body.status })
return jsonResponse({ row })
}
Expand Down
Loading