[Bug]: Publishing a page row on its own never reaches visitors, and can 404 the home page
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:
- The edit never reaches visitors.
getPublishedPageBySlugreturnsnullfor that page, soresolvePublicRoute(server/publish/publicRouter.ts:154) falls through tonot-found. - It can take the rest of the site's routing with it.
getLatestPublishedSiteSnapshotorders bydata_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 returnsnull. That snapshot is whatserver/publish/publishedSnapshotCache.ts(entry routes and the 404 page),server/forms/handler.ts,server/handlers/cms/moduleJs.tsandserver/publish/publishRow.tsall read.
This is reachable through supported flows: the row publish endpoint (server/handlers/cms/data/rows.ts → publishDataRow) 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
Writer — persistDataRowPublish, server/repositories/data/publish.ts:
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.
Readers — server/repositories/publish.ts, all three:
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_idgetPublishedPageBySlug, getPublishedPageSnapshotById and getLatestPublishedSiteSnapshot.
Only the full publish (publishDraftSite → publishPagesSnapshot) 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.ts — it passes on main, i.e. every asserted (broken) behaviour is present.
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() -> nullAnd 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_aboutEnvironment
mainat39760355- 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).
Source: CoreBunch/Instatic