Skip to content
Merged
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
4 changes: 2 additions & 2 deletions server/ai/tools/content/writeTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const setDocumentFieldTool: AiTool = {
execution: 'browser',
requiredCapabilities: DOCUMENT_EDIT_CAPS,
description:
"Write one field on a document. `value` shape depends on the field type (read content_get_collection_schema first if unsure): text/longText/richText/url/email → string; number → number; boolean → boolean; date/dateTime → ISO string; select → option id; multiSelect → option id[]; media → { id } or { id }[]; relation → { rowId } or { rowId }[]; body → markdown string. Bridge converts markdown ↔ Tiptap automatically for body.",
"Write one field on a document. The document MUST be the active one — call content_set_active_document first, or the write is refused. (content_create_document leaves the new document active, so create-then-fill needs no extra call.) `value` shape depends on the field type (read content_get_collection_schema first if unsure): text/longText/richText/url/email → string; number → number; boolean → boolean; date/dateTime → ISO string; select → option id; multiSelect → option id[]; media → { id } or { id }[]; relation → { rowId } or { rowId }[]; body → markdown string. Bridge converts markdown ↔ Tiptap automatically for body.",
inputSchema: SetDocumentFieldInput,
}

Expand All @@ -144,7 +144,7 @@ const setDocumentFieldsTool: AiTool = {
execution: 'browser',
requiredCapabilities: DOCUMENT_EDIT_CAPS,
description:
'Batch-write multiple fields on one document. `fields` is Record<fieldId, value>; same per-type shapes as content_set_document_field. Prefer this when generating a whole post (title + slug + body + seo* in one call).',
'Batch-write multiple fields on one document. The document MUST be the active one — call content_set_active_document first, or the write is refused. `fields` is Record<fieldId, value>; same per-type shapes as content_set_document_field. Prefer this when generating a whole post (title + slug + body + seo* in one call), and when filling several documents in sequence set each one active before writing to it.',
inputSchema: SetDocumentFieldsInput,
}

Expand Down
6 changes: 5 additions & 1 deletion server/publish/bakeDataRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { renderPublishedDataRowTemplate } from './publicRenderer'
import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline'
import { writeArtefact } from './staticArtefact'
import { getLatestSnapshotForVersion } from './publishedSnapshotCache'
import { snapshotForEntryRoute } from './entryTemplateSnapshot'

interface DataRowBakeResult {
/** Routes successfully baked into the slot. */
Expand Down Expand Up @@ -91,7 +92,10 @@ export async function bakePublishedDataRowArtefacts(
const row = await getPublishedDataRowByRoute(db, route.tableRouteBase, route.rowSlug)
if (!row) continue
const syntheticUrl = new URL(`http://localhost${urlPath}`)
const rendered = await renderPublishedDataRowTemplate(siteSnapshot, row, {
// Runtime assets come from this table's entry template, not from the
// arbitrary page the site-wide snapshot happens to name.
const snapshot = await snapshotForEntryRoute(db, siteSnapshot, route.tableSlug)
const rendered = await renderPublishedDataRowTemplate(snapshot, row, {
db,
url: syntheticUrl,
publishVersion,
Expand Down
72 changes: 72 additions & 0 deletions server/publish/entryTemplateSnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Runtime assets for a route rendered through a template.
*
* Runtime assets are per-PAGE: `publishSite.ts` bundles them once per row in
* `site.pages`, honouring each script's `scope` through
* `assetScopeAppliesToPage`. Entry routes (`/rooms/<slug>`) are not pages —
* they are rendered per request from a template page plus a data row — so they
* have no manifest of their own and have to borrow their template's.
*
* They used to borrow whatever `getLatestPublishedSiteSnapshot` returned, which
* is `order by data_rows.created_at asc limit 1`: the first published page ever
* created. That getter exists to supply `site_json`; its `runtime_assets_json`
* rode along by accident. So every entry route on a site served one arbitrary
* page's scripts, and the scope predicate was never consulted on that path at
* all — a script scoped to two pages shipped on all of them, and a script the
* oldest page did not carry never shipped anywhere.
*
* `getLatestPublishedSiteSnapshot` no longer carries runtime assets, so the
* failure mode if this resolution misses is "no scripts", never "someone
* else's scripts".
*/
import type { DbClient } from '../db/client'
import type { PublishedPageSnapshot } from '../repositories/publish'
import { getPublishedPageSnapshotById } from '../repositories/publish'
import { resolveNotFoundTemplate, resolveTemplateChain } from '@core/templates/templateMatching'

/**
* `siteSnapshot` with the runtime manifest of the page that will actually
* render `pageId`. The site document is kept from `siteSnapshot` so it stays
* the one the caller resolved its template chain against, even if a publish
* lands between the two reads.
*/
async function withRuntimeAssetsOfPage(
db: DbClient,
siteSnapshot: PublishedPageSnapshot,
pageId: string,
): Promise<PublishedPageSnapshot> {
const own = await getPublishedPageSnapshotById(db, pageId)
// The rendering page's manifest is authoritative, INCLUDING when it has
// none. Merging only the present case would let a manifest that arrived on
// `siteSnapshot` from anywhere else survive onto a route it was never
// scoped to — which is the whole defect.
const { runtimeAssets: _discarded, ...withoutAssets } = siteSnapshot
return own?.runtimeAssets
? { ...withoutAssets, runtimeAssets: own.runtimeAssets }
: withoutAssets
}

/**
* Snapshot to render an entry route of `tableSlug` with — the innermost
* template in its chain supplies the runtime manifest.
*/
export async function snapshotForEntryRoute(
db: DbClient,
siteSnapshot: PublishedPageSnapshot,
tableSlug: string,
): Promise<PublishedPageSnapshot> {
const chain = resolveTemplateChain(siteSnapshot.site, { kind: 'entry', tableSlug })
const innermost = chain[chain.length - 1]
if (!innermost) return siteSnapshot
return await withRuntimeAssetsOfPage(db, siteSnapshot, innermost.id)
}

/** Snapshot to render the 404 route with — same reasoning as entry routes. */
export async function snapshotForNotFoundRoute(
db: DbClient,
siteSnapshot: PublishedPageSnapshot,
): Promise<PublishedPageSnapshot> {
const template = resolveNotFoundTemplate(siteSnapshot.site)
if (!template) return siteSnapshot
return await withRuntimeAssetsOfPage(db, siteSnapshot, template.id)
}
12 changes: 9 additions & 3 deletions server/publish/publicRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
import { NOT_FOUND_ARTEFACT_URL_PATH, readArtefact } from './staticArtefact'
import { getOrRender, peek } from './renderCache'
import { getLatestSnapshotForVersion } from './publishedSnapshotCache'
import { snapshotForEntryRoute, snapshotForNotFoundRoute } from './entryTemplateSnapshot'
import { getPublishVersion } from './publishState'
import { canonicalRenderQuery } from './loopPrefetch'

Expand Down Expand Up @@ -173,7 +174,10 @@ async function resolvePublicRoute(
// full-site parse.
const siteSnapshot = await getLatestSnapshotForVersion(db, getPublishVersion())
if (!siteSnapshot) return { kind: 'not-found' }
return { kind: 'row', snapshot: siteSnapshot, row }
// That snapshot carries no runtime manifest — an entry route takes the one
// belonging to the template that actually renders it.
const snapshot = await snapshotForEntryRoute(db, siteSnapshot, row.tableSlug)
return { kind: 'row', snapshot, row }
}

const redirect = await getDataRowRedirectByRoute(db, route.tableRouteBase, route.rowSlug)
Expand Down Expand Up @@ -320,8 +324,10 @@ export async function renderNotFoundResponse(
return new Response(warm.body, { headers: warm.headers, status: 404 })
}

const snapshot = await getLatestSnapshotForVersion(db, getPublishVersion())
if (!snapshot || !resolveNotFoundTemplate(snapshot.site)) return null
const siteSnapshot = await getLatestSnapshotForVersion(db, getPublishVersion())
if (!siteSnapshot || !resolveNotFoundTemplate(siteSnapshot.site)) return null
// Same as entry routes: the 404 template supplies its own runtime manifest.
const snapshot = await snapshotForNotFoundRoute(db, siteSnapshot)

const syntheticUrl = new URL(NOT_FOUND_ARTEFACT_URL_PATH, url.origin)
const cached = await getOrRender(cacheKey, async () => {
Expand Down
6 changes: 5 additions & 1 deletion server/publish/publishRow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type PreviousPublishedRoute,
} from '../repositories/data/publish'
import { getLatestPublishedSiteSnapshot } from '../repositories/publish'
import { snapshotForEntryRoute } from './entryTemplateSnapshot'
import { renderPublishedDataRowTemplate } from './publicRenderer'
import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline'
import { removeArtefactInPlace, updateArtefactInPlace } from './staticArtefact'
Expand Down Expand Up @@ -132,7 +133,10 @@ async function writeDataRowArtefact(

const newPath = publicDataPath(tableInfo.tableRouteBase, publishedRow.slug)
const syntheticUrl = new URL(`http://localhost${newPath}`)
const rendered = await renderPublishedDataRowTemplate(siteSnapshot, publishedDataRow, {
// Runtime assets come from this table's entry template, not from the
// arbitrary page the site-wide snapshot happens to name.
const snapshot = await snapshotForEntryRoute(db, siteSnapshot, tableInfo.tableSlug)
const rendered = await renderPublishedDataRowTemplate(snapshot, publishedDataRow, {
db,
url: syntheticUrl,
publishVersion,
Expand Down
17 changes: 15 additions & 2 deletions server/repositories/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,25 @@ export async function getPublishedPageSnapshotById(
return rows[0] ? snapshotFromQueryRow(rows[0]) : null
}

/**
* Any published page's snapshot, used purely as a carrier for `site_json` —
* routes that are not themselves pages (entry routes, the 404) need the site
* document to resolve their template chain.
*
* It deliberately does NOT carry runtime assets. Those are per-page, and the
* arbitrary page this returns (the first created, per the `order by`) is
* almost never the page that renders the request. Letting its
* `runtime_assets_json` ride along meant every entry route on a site served
* one unrelated page's scripts, with the scope predicate never consulted.
* Callers needing a manifest resolve the page that actually renders and take
* its own — see `server/publish/entryTemplateSnapshot.ts`.
*/
export async function getLatestPublishedSiteSnapshot(
db: DbClient,
): Promise<PublishedPageSnapshot | null> {
const { rows } = await db<SnapshotQueryRow>`
select data_rows.id as row_id,
site_snapshots.site_json,
data_row_versions.runtime_assets_json,
site_snapshots.importmap_body,
site_snapshots.importmap_sha256
from data_rows
Expand All @@ -341,5 +353,6 @@ export async function getLatestPublishedSiteSnapshot(
order by data_rows.created_at asc
limit 1
`
return rows[0] ? snapshotFromQueryRow(rows[0]) : null
const row = rows[0]
return row ? snapshotFromQueryRow({ ...row, runtime_assets_json: null }) : null
}
72 changes: 72 additions & 0 deletions src/__tests__/data/contentAdmin.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,78 @@ describe('ContentPage', () => {
expect(params.get('row')).toBe('article_2')
})

it('publishing another document does not steal the active document', async () => {
// `applyStatus` used to call `updateSelectedEntry` for whatever row it
// published, active or not. That retargeted the workspace — discarding the
// author's unsaved draft — and left a tool loop of
// `set_document_fields → set_document_status` writing one document behind
// itself, so every field write after the first was refused.
const postA = makeRow('post_a', 'posts', { title: 'Open post', slug: 'open-post', seoTitle: '' })
const postB = makeRow('post_b', 'posts', { title: 'Other post', slug: 'other-post', seoTitle: '' })

globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
const method = init?.method ?? 'GET'

if (url === '/admin/api/cms/data/tables' && method === 'GET') {
return json({ tables: [makeTable('posts', 'Posts', 'posts', '/posts', 'Post', 'Posts')] })
}
if (url === '/admin/api/cms/data/tables/posts/rows' && method === 'GET') {
return json({ rows: [postA, postB] })
}
if (url === '/admin/api/cms/data/rows/post_a' && method === 'GET') return json({ row: postA })
if (url === '/admin/api/cms/data/rows/post_b' && method === 'GET') return json({ row: postB })
if (url === '/admin/api/cms/data/rows/post_a' && method === 'PATCH') {
const body = JSON.parse(String(init?.body))
return json({ row: makeRow('post_a', 'posts', body.cells) })
}
if (url === '/admin/api/cms/data/rows/post_b/publish' && method === 'POST') {
return json({ row: { ...postB, status: 'published', publishedAt: '2026-05-01T10:02:00.000Z' } })
}
if (url === '/admin/api/cms/data/authors' && method === 'GET') return json({ authors: [] })
if (url === '/admin/api/cms/media' && method === 'GET') return json({ assets: [] })

const ambient = ambientFetchFallback(url)
if (ambient) return ambient
return json({ error: `Unhandled ${method} ${url}` }, 500)
}

render(
<AdminTestProviders>
<ContentPage />
</AdminTestProviders>,
)
expect(await screen.findByRole('region', { name: 'Posts' })).toBeDefined()

// Each call gets its own act() so React commits in between and the bridge's
// workspace ref refreshes. Batching them hides the bug: the ref would still
// hold the pre-publish workspace and the last write would pass either way.
let statusResult: Awaited<ReturnType<typeof executeContentTool>> | null = null
let writeResult: Awaited<ReturnType<typeof executeContentTool>> | null = null
await act(async () => {
await executeContentTool('content_set_active_document', { documentId: 'post_a' })
})
await act(async () => {
// Publish the OTHER document…
statusResult = await executeContentTool('content_set_document_status', {
documentId: 'post_b',
status: 'published',
})
})
await act(async () => {
// …post_a must still be the active document, so this write must land.
writeResult = await executeContentTool('content_set_document_field', {
documentId: 'post_a',
fieldId: 'seoTitle',
value: 'Still mine',
})
})

expect(statusResult?.ok).toBe(true)
expect(writeResult?.ok).toBe(true)
expect(String(writeResult?.error ?? '')).not.toContain('not the active doc')
})

it('uses content-specific rail panels instead of editor-only panels', async () => {
render(
<AdminTestProviders>
Expand Down
106 changes: 106 additions & 0 deletions src/__tests__/publisher/entryRouteRuntimeScope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Entry routes take their runtime manifest from the template that renders
* them, not from whatever page the site-wide snapshot happens to name.
*
* `getLatestPublishedSiteSnapshot` is `order by data_rows.created_at asc limit
* 1` — the first published page ever created. It exists to carry `site_json`,
* and its `runtime_assets_json` used to ride along. Entry routes are rendered
* from a template plus a data row rather than from a page, so they inherited
* that manifest wholesale: every entry route on a site served one arbitrary
* page's scripts, and `assetScopeAppliesToPage` was never consulted on the
* path at all.
*
* Over-inclusion was what surfaced in practice — a script scoped to two pages
* shipping on ten entry routes — but the same bug under-includes just as
* easily: if the oldest page carries no scripts, a scoped script reaches
* nothing.
*/
import { describe, expect, it } from 'bun:test'
import type { DbClient } from '../../../server/db'
import type { PublishedPageSnapshot } from '../../../server/repositories/publish'
import { snapshotForEntryRoute } from '../../../server/publish/entryTemplateSnapshot'
import { makeSite } from '../fixtures'

const postsTarget = { kind: 'postTypes' as const, tableSlugs: ['posts'] }

function runtimeAssets(name: string) {
return {
scripts: [{ publicPath: `/_instatic/assets/${name}/classic/001-${name}.js`, placement: 'body-end' as const }],
}
}

/** A site whose first page is unrelated and whose second is the entry template. */
function siteSnapshot(): PublishedPageSnapshot {
const site = makeSite()
site.pages[0].id = 'oldest-page'
site.pages.push({
...structuredClone(site.pages[0]),
id: 'post-template',
slug: 'post-template',
template: { enabled: true, target: postsTarget, priority: 10 },
})
return { cmsSnapshotVersion: 1, pageRowId: 'oldest-page', site }
}

/** Stands in for the DB: only `getPublishedPageSnapshotById` is reached. */
function dbReturning(byPageId: Record<string, unknown>): DbClient {
const fake = (async (_strings: TemplateStringsArray, ...params: unknown[]) => {
const pageId = String(params[0])
const assets = byPageId[pageId]
return assets
? { rows: [{ row_id: pageId, site_json: makeSite(), runtime_assets_json: assets }], rowCount: 1 }
: { rows: [], rowCount: 0 }
}) as unknown as DbClient
return fake
}

describe('entry-route runtime manifest', () => {
it('uses the entry template\'s own manifest, not the site snapshot\'s', async () => {
const snapshot = siteSnapshot()
const db = dbReturning({ 'post-template': runtimeAssets('template') })

const resolved = await snapshotForEntryRoute(db, snapshot, 'posts')

expect(resolved.runtimeAssets?.scripts[0]?.publicPath).toContain('001-template.js')
})

it('serves no scripts when the template has none, rather than another page\'s', async () => {
// Reproduce the old shape: a site snapshot already carrying the oldest
// page's manifest, and a template with none of its own. The old code
// passed that straight through, so every entry route on the site served
// `001-oldest.js`.
const snapshot = { ...siteSnapshot(), runtimeAssets: runtimeAssets('oldest') }
const db = dbReturning({})

const resolved = await snapshotForEntryRoute(db, snapshot, 'posts')

expect(resolved.runtimeAssets?.scripts[0]?.publicPath ?? '').not.toContain('001-oldest.js')
})

it('overrides a stale manifest on the site snapshot with the template\'s', async () => {
const snapshot = { ...siteSnapshot(), runtimeAssets: runtimeAssets('oldest') }
const db = dbReturning({ 'post-template': runtimeAssets('template') })

const resolved = await snapshotForEntryRoute(db, snapshot, 'posts')

expect(resolved.runtimeAssets?.scripts[0]?.publicPath).toContain('001-template.js')
})

it('leaves the site document untouched so the resolved chain still applies', async () => {
const snapshot = siteSnapshot()
const db = dbReturning({ 'post-template': runtimeAssets('template') })

const resolved = await snapshotForEntryRoute(db, snapshot, 'posts')

expect(resolved.site).toBe(snapshot.site)
})

it('falls back to the site snapshot when the table has no entry template', async () => {
const snapshot = siteSnapshot()
const db = dbReturning({ 'post-template': runtimeAssets('template') })

const resolved = await snapshotForEntryRoute(db, snapshot, 'no-such-table')

expect(resolved).toBe(snapshot)
})
})
Loading
Loading