#534·Instatic

[Bug]: Publishing a page row on its own never reaches visitors, and can 404 the home page

Author: MathiasQMCreated Sep 13, 2026Updated Sep 13, 2026

What happens

Publishing a page row on its own — the per-row publish path, publishDataRow — takes the page off the site instead of publishing it. The row ends up status = 'published' with an active_version_id pointing at a data_row_versions row whose site_snapshot_id is NULL, and every published-page read inner-joins site_snapshots through exactly that column, so the page stops resolving.

Two consequences, both reproduced below on main:

  1. The edit never reaches visitors. getPublishedPageBySlug returns null for that page, so resolvePublicRoute (server/publish/publicRouter.ts:154) falls through to not-found.
  2. It can take the rest of the site's routing with it. getLatestPublishedSiteSnapshot orders by data_rows.created_at asc, so per-row publishing the oldest published page moves the carrier snapshot to another page — and once every published page has been through this path, it returns null. That snapshot is what server/publish/publishedSnapshotCache.ts (entry routes and the 404 page), server/forms/handler.ts, server/handlers/cms/moduleJs.ts and server/publish/publishRow.ts all read.

This is reachable through supported flows: the row publish endpoint (server/handlers/cms/data/rows.tspublishDataRow) accepts a pages row, and so does the scheduled publish tick — src/__tests__/server/publishScheduler.test.ts seeds a pages row with scheduled_publish_at and asserts tickPublishScheduler publishes it. So scheduling a page publish is enough to hit this.

What I expected

Publishing a page row on its own either publishes it — the version carries a site snapshot, and the page keeps resolving with the new content — or is refused, so the page stays as it was.

The writer and the readers that disagree

WriterpersistDataRowPublish, server/repositories/data/publish.ts:

sql
insert into data_row_versions
  (id, row_id, version_number, cells_json, slug, published_by_user_id)
values (...)

No site_snapshot_id. data_rows.active_version_id is then repointed at that version.

Readersserver/repositories/publish.ts, all three:

sql
join data_row_versions on data_row_versions.id = data_rows.active_version_id
join site_snapshots on site_snapshots.id = data_row_versions.site_snapshot_id

getPublishedPageBySlug, getPublishedPageSnapshotById and getLatestPublishedSiteSnapshot.

Only the full publish (publishDraftSitepublishPagesSnapshot) writes a site_snapshots row and links versions to it, so a page is routable only for as long as its active version is one a full publish created.

Minimal reproduction

main at 39760355, Bun 1.4.2, default SQLite test DB. Save as src/__tests__/server/perRowPagePublishRepro.test.ts and run bun test src/__tests__/server/perRowPagePublishRepro.test.tsit passes on main, i.e. every asserted (broken) behaviour is present.

typescript
import { describe, expect, it } from 'bun:test'
import type { SiteShell } from '@core/page-tree'
import { normalizeSiteRuntimeConfig } from '@core/site-runtime'
import { saveDraftSite } from '../../../server/repositories/site'
import {
  getPublishedPageBySlug,
  getLatestPublishedSiteSnapshot,
} from '../../../server/repositories/publish'
import { publishDraftSite } from '../../../server/publish/publishSite'
import { publishDataRow } from '../../../server/publish/publishRow'
import { createDataRow, saveDataRowDraft } from '../../../server/repositories/data'
import { pageToCells } from '../../../src/core/data/pageFromRow'
import { MAIN_SCOPE } from '../../../server/branches/scope'
import { createTestDb } from '../helpers/createTestDb'

function makeShell(): SiteShell {
  return {
    id: 'project_1',
    name: 'Repro Site',
    files: [],
    visualComponents: [],
    breakpoints: [{ id: 'desktop', label: 'Desktop', width: 1440, icon: 'monitor' }],
    settings: { shortcuts: {} },
    styleRules: {},
    packageJson: { dependencies: {}, devDependencies: {} },
    runtime: normalizeSiteRuntimeConfig(undefined),
    createdAt: 1000,
    updatedAt: 2000,
  }
}

function makePage(id: string, slug: string, title: string, text: string) {
  return {
    id, title, slug, rootNodeId: 'root',
    nodes: {
      root: { id: 'root', moduleId: 'base.body', props: {}, breakpointOverrides: {}, children: ['text_1'], classIds: [] },
      text_1: { id: 'text_1', moduleId: 'base.text', props: { text, tag: 'h1' }, breakpointOverrides: {}, children: [], classIds: [] },
    },
  }
}

describe('per-row publish of a pages row', () => {
  it('takes the page offline instead of publishing it', async () => {
    const { db, cleanup } = await createTestDb()
    try {
      await db`
        insert into users (id, email, email_normalized, display_name, password_hash, role_id)
        values ('admin_1', '[email protected]', '[email protected]', 'Admin', 'x', 'admin')
      `
      await saveDraftSite(db, MAIN_SCOPE, makeShell())
      for (const page of [
        makePage('page_home', 'index', 'Home', 'Home v1'),
        makePage('page_about', 'about', 'About', 'About v1'),
      ]) {
        await createDataRow(db, MAIN_SCOPE, {
          id: page.id, tableId: 'pages', cells: pageToCells(page), slug: page.slug,
        }, 'admin_1')
        await new Promise((r) => setTimeout(r, 1100)) // distinct created_at
      }

      await publishDraftSite(db, 'admin_1')
      expect(await getPublishedPageBySlug(db, 'about')).not.toBeNull()
      expect((await getLatestPublishedSiteSnapshot(db))?.pageRowId).toBe('page_home')

      // Edit the About page and publish that row on its own.
      const edited = makePage('page_about', 'about', 'About', 'About v2')
      await saveDataRowDraft(db, MAIN_SCOPE, 'page_about', { cells: pageToCells(edited), slug: 'about' }, 'admin_1')
      await publishDataRow(db, 'page_about', 'admin_1')

      // The row is 'published' and its active version carries no snapshot id.
      const { rows: versions } = await db<{ row_id: string; site_snapshot_id: string | null }>`
        select data_rows.id as row_id, data_row_versions.site_snapshot_id
        from data_rows
        join data_row_versions on data_row_versions.id = data_rows.active_version_id
        where data_rows.id = 'page_about'
      `
      expect(versions[0]?.site_snapshot_id).toBeNull()

      // ...so the page no longer resolves: the public router returns not-found.
      expect(await getPublishedPageBySlug(db, 'about')).toBeNull()

      // Doing the same to the oldest page also moves the carrier snapshot that
      // entry routes, the 404 page, forms and module JS all read.
      await publishDataRow(db, 'page_home', 'admin_1')
      expect(await getPublishedPageBySlug(db, 'index')).toBeNull()
      expect(await getLatestPublishedSiteSnapshot(db)).toBeNull()
    } finally {
      await cleanup()
    }
  }, 30_000)
})

Instrumented output from the same sequence, printing what each read returns at every step:

STEP 1 — after a full publish
  getPublishedPageBySlug("index") -> snapshot, headline "Home v1"
  getPublishedPageBySlug("about") -> snapshot, headline "About v1"
  getLatestPublishedSiteSnapshot() -> carrier row page_home

STEP 2 — after `publishDataRow` on the About page row
  getPublishedPageBySlug("about") -> null
  getLatestPublishedSiteSnapshot() -> carrier row page_home
  data_row_versions: [
    {"row_id":"page_home","site_snapshot_id":"eVVHo6zhE5xsx5n2LWgVa"},
    {"row_id":"page_about","site_snapshot_id":"eVVHo6zhE5xsx5n2LWgVa"},
    {"row_id":"page_about","site_snapshot_id":null}
  ]
  data_rows: [
    {"id":"page_home","status":"published","active_version_id":"pLNw44293ePVGoZu-_p7s"},
    {"id":"page_about","status":"published","active_version_id":"Zvq7meADN272U_7NIMoVT"}
  ]

STEP 3 — after `publishDataRow` on the Home page row
  getPublishedPageBySlug("index") -> null
  getLatestPublishedSiteSnapshot() -> null

And with three pages, per-row publishing only the oldest one:

AFTER FULL PUBLISH
  getPublishedPageBySlug("index") -> snapshot
  getLatestPublishedSiteSnapshot() -> carrier row page_home
AFTER publishDataRow ON THE HOME PAGE ROW (the oldest)
  getPublishedPageBySlug("index") -> null
  getPublishedPageBySlug("about") -> snapshot
  getLatestPublishedSiteSnapshot() -> carrier row page_about

Environment

  • main at 39760355
  • Bun 1.4.2
  • SQLite (the default createTestDb), macOS

Possible directions

Either the per-row publish path could carry a site snapshot for a pages row — reusing the current one, or writing a new one the way the full publish does — or it could refuse a pages row outright and leave page publishing to publishDraftSite; the scheduled-publish tick would need whichever answer is chosen, since it reaches the same function.

Related: #532 (a different symptom of the same per-row publish path).