From 8b3214d6aa61bb605766f6946ef5d24ad24d12ad Mon Sep 17 00:00:00 2001 From: Flying Web Solutions Date: Wed, 4 Nov 2026 13:25:37 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(publish):=20read?= =?UTF-8?q?=20only=20the=20template's=20runtime=20manifest=20per=20entry?= =?UTF-8?q?=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full publish bakes every published data row whose table has an entry template. For each row, snapshotForEntryRoute borrowed the template page's runtime manifest through getPublishedPageSnapshotById, which also selected and parsed the whole published site_json, only to discard it. Publish cost grew as rows × site size: 51 of 56 MB read per publish on a 23-row dev site, and minutes on a Postgres-backed production site with several templates. - Replace getPublishedPageSnapshotById (its only caller) with getPublishedPageRuntimeAssets, which selects runtime_assets_json alone. - Resolve each table's entry snapshot once per bake, not once per row. - Live entry-route renders skip the same redundant site_json read. Co-Authored-By: Claude Opus 5.5 --- server/publish/bakeDataRows.ts | 16 ++++++++++- server/publish/entryTemplateSnapshot.ts | 10 +++---- server/repositories/publish.ts | 28 ++++++++++--------- .../publisher/entryRouteRuntimeScope.test.ts | 23 ++++++++++++--- .../server/cmsTemplateRoutes.test.ts | 5 ++++ src/__tests__/server/publicForms.test.ts | 4 +-- 6 files changed, 61 insertions(+), 25 deletions(-) diff --git a/server/publish/bakeDataRows.ts b/server/publish/bakeDataRows.ts index 737dd8fee..8a3eee3df 100644 --- a/server/publish/bakeDataRows.ts +++ b/server/publish/bakeDataRows.ts @@ -22,6 +22,7 @@ import type { DbClient } from '../db/client' import type { SiteCssBundle } from '@core/publisher' +import type { PublishedPageSnapshot } from '../repositories/publish' import { resolveTemplateChain } from '@core/templates' import { normalizeRouteBase } from '@core/templates/templateMatching' import { @@ -85,6 +86,19 @@ export async function bakePublishedDataRowArtefacts( return has } + // Every row of a table renders through the same template, so its snapshot + // (the template's runtime manifest on the shared site) resolves once per + // table, not once per row. + const entrySnapshots = new Map>() + const entrySnapshotFor = (tableSlug: string): Promise => { + let snapshot = entrySnapshots.get(tableSlug) + if (!snapshot) { + snapshot = snapshotForEntryRoute(db, siteSnapshot, tableSlug) + entrySnapshots.set(tableSlug, snapshot) + } + return snapshot + } + for (const route of routes) { if (!hasEntryChain(route.tableSlug)) continue const urlPath = publicRowPath(route.tableRouteBase, route.rowSlug) @@ -94,7 +108,7 @@ export async function bakePublishedDataRowArtefacts( const syntheticUrl = new URL(`http://localhost${urlPath}`) // 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 snapshot = await entrySnapshotFor(route.tableSlug) const rendered = await renderPublishedDataRowTemplate(snapshot, row, { db, url: syntheticUrl, diff --git a/server/publish/entryTemplateSnapshot.ts b/server/publish/entryTemplateSnapshot.ts index cd4eca058..7c9c7190e 100644 --- a/server/publish/entryTemplateSnapshot.ts +++ b/server/publish/entryTemplateSnapshot.ts @@ -21,7 +21,7 @@ */ import type { DbClient } from '../db/client' import type { PublishedPageSnapshot } from '../repositories/publish' -import { getPublishedPageSnapshotById } from '../repositories/publish' +import { getPublishedPageRuntimeAssets } from '../repositories/publish' import { resolveNotFoundTemplate, resolveTemplateChain } from '@core/templates/templateMatching' /** @@ -35,15 +35,15 @@ async function withRuntimeAssetsOfPage( siteSnapshot: PublishedPageSnapshot, pageId: string, ): Promise { - const own = await getPublishedPageSnapshotById(db, pageId) + // Only the manifest is read: the site document is already in hand, and + // re-fetching it per route made the full-publish row bake O(rows × site). + const runtimeAssets = await getPublishedPageRuntimeAssets(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 + return runtimeAssets ? { ...withoutAssets, runtimeAssets } : withoutAssets } /** diff --git a/server/repositories/publish.ts b/server/repositories/publish.ts index 10a576b05..0f8777fbe 100644 --- a/server/repositories/publish.ts +++ b/server/repositories/publish.ts @@ -17,7 +17,7 @@ * getDraftSiteDocument — assemble the draft SiteDocument from rows * persistSitePublish — transactional write of one publish * getPublishedPageBySlug — look up a published page snapshot by slug - * getPublishedPageSnapshotById — same, by page row id + * getPublishedPageRuntimeAssets — a published page's runtime manifest only * getLatestPublishedSiteSnapshot — first published page snapshot (for 404s etc.) * getDraftPublishStatus — compare draft vs published state for the UI */ @@ -404,28 +404,30 @@ export async function getPublishedPageBySlug( return rows[0] ? snapshotFromQueryRow(rows[0]) : null } -export async function getPublishedPageSnapshotById( +/** + * The runtime manifest of a published page's active version — and nothing + * else. Routes rendered through a template (entry routes, the 404) borrow + * their template page's manifest. Selecting the joined `site_json` here + * re-fetched and re-parsed the whole site document on every call, which made + * a full publish's row bake cost O(rows × site size) in DB transfer and JSON + * parsing — minutes on a site with a few hundred entry rows. + */ +export async function getPublishedPageRuntimeAssets( db: DbClient, pageId: string, -): Promise { - const { rows } = await db` - 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, - data_row_versions.published_at, - (select min(versions.published_at) from data_row_versions versions where versions.row_id = data_rows.id) as first_published_at +): Promise { + const { rows } = await db<{ runtime_assets_json: PublishedPageRuntimeAssets | null }>` + select data_row_versions.runtime_assets_json from data_rows 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 where data_rows.id = ${pageId} and data_rows.table_id = 'pages' and data_rows.status = 'published' and data_rows.deleted_at is null limit 1 ` - return rows[0] ? snapshotFromQueryRow(rows[0]) : null + const assets = rows[0]?.runtime_assets_json + return assets && assets.scripts.length > 0 ? assets : null } /** diff --git a/src/__tests__/publisher/entryRouteRuntimeScope.test.ts b/src/__tests__/publisher/entryRouteRuntimeScope.test.ts index b214c7efb..6a261204e 100644 --- a/src/__tests__/publisher/entryRouteRuntimeScope.test.ts +++ b/src/__tests__/publisher/entryRouteRuntimeScope.test.ts @@ -42,13 +42,14 @@ function siteSnapshot(): PublishedPageSnapshot { return { cmsSnapshotVersion: 1, pageRowId: 'oldest-page', site } } -/** Stands in for the DB: only `getPublishedPageSnapshotById` is reached. */ -function dbReturning(byPageId: Record): DbClient { - const fake = (async (_strings: TemplateStringsArray, ...params: unknown[]) => { +/** Stands in for the DB: only `getPublishedPageRuntimeAssets` is reached. */ +function dbReturning(byPageId: Record, queries: string[] = []): DbClient { + const fake = (async (strings: TemplateStringsArray, ...params: unknown[]) => { + queries.push(strings.join('?')) const pageId = String(params[0]) const assets = byPageId[pageId] return assets - ? { rows: [{ row_id: pageId, published_at: '2026-01-01T00:00:00Z', first_published_at: '2026-01-01T00:00:00Z', site_json: makeSite(), runtime_assets_json: assets }], rowCount: 1 } + ? { rows: [{ runtime_assets_json: assets }], rowCount: 1 } : { rows: [], rowCount: 0 } }) as unknown as DbClient return fake @@ -95,6 +96,20 @@ describe('entry-route runtime manifest', () => { expect(resolved.site).toBe(snapshot.site) }) + it('reads only the template\'s manifest, never the site document again', async () => { + // The caller already holds the site. Re-selecting `site_json` per route + // made a full publish's row bake re-fetch and re-parse the whole site + // once per entry row — minutes of publish time on a real site. + const queries: string[] = [] + const db = dbReturning({ 'post-template': runtimeAssets('template') }, queries) + + await snapshotForEntryRoute(db, siteSnapshot(), 'posts') + + expect(queries).toHaveLength(1) + expect(queries[0]).toContain('runtime_assets_json') + expect(queries[0]).not.toContain('site_json') + }) + 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') }) diff --git a/src/__tests__/server/cmsTemplateRoutes.test.ts b/src/__tests__/server/cmsTemplateRoutes.test.ts index 454e2bb95..3a2f6ca5a 100644 --- a/src/__tests__/server/cmsTemplateRoutes.test.ts +++ b/src/__tests__/server/cmsTemplateRoutes.test.ts @@ -24,6 +24,11 @@ function makeTemplateRouteFakeDb(handlers: QueryHandler[]) { if (sql.startsWith('select logical_id, name, slug, kind') && sql.includes('from data_tables')) { return { rows: [], rowCount: 0 } as unknown as DbResult } + // Entry routes borrow their template page's runtime manifest; these + // fixture templates carry no scripts. + if (sql.startsWith('select data_row_versions.runtime_assets_json')) { + return { rows: [], rowCount: 0 } as unknown as DbResult + } for (const handler of handlers) { const result = handler(sql, params) if (result) return result diff --git a/src/__tests__/server/publicForms.test.ts b/src/__tests__/server/publicForms.test.ts index 09891a213..b26900eae 100644 --- a/src/__tests__/server/publicForms.test.ts +++ b/src/__tests__/server/publicForms.test.ts @@ -119,9 +119,9 @@ function makeDb(options: { const tableRows = options.tableRows ?? { newsletter_submissions: newsletterTableRow } const db = createFakeDb(async (rawSql, params): Promise => { const sql = rawSql.replace(/\s+/g, ' ').trim().toLowerCase() - // getPublishedPageSnapshotById — joins data_row_versions to site_snapshots. + // The published-snapshot getters join data_row_versions to site_snapshots. // Must be matched before the generic `select data_rows.id` branch below - // (the snapshot getter's SELECT also starts with `select data_rows.id`). + // (the snapshot getters' SELECT also starts with `select data_rows.id`). if (sql.includes('site_snapshots.site_json')) { return { rows: [{ From 24f4228b9475993750c2efcbe9ccae583b5708c8 Mon Sep 17 00:00:00 2001 From: Flying Web Solutions Date: Wed, 4 Nov 2026 13:25:37 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=96=20chore(release):=20prepare=20?= =?UTF-8?q?Instatic=200.0.26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc9135ad7..f99745777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ This project is pre-1.0. Breaking changes may appear in minor or patch releases ## Unreleased +## 0.0.26 - 2026-11-04 + +### Publishing + +- Publishing a site with post-type templates (blog articles, guides, projects…) is much faster. Each entry page baked during a full publish used to download and parse the whole site document again just to find its template's scripts; it now reads only the script list, and each collection looks up its template once. Visitor requests for entry pages that aren't cached yet skip the same extra read. + ## 0.0.25 - 2026-11-03 ### AI editing and connection reliability diff --git a/package.json b/package.json index 35ba7f98c..bba0fc047 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "instatic", "private": true, - "version": "0.0.25", + "version": "0.0.26", "engines": { "bun": ">=1.4.0 <1.5.0" },