From 0e33ef52cbf22d516f2f5342c56b9e049fb8731b Mon Sep 17 00:00:00 2001 From: Norbert Gocht Date: Wed, 14 Oct 2026 18:52:48 +0200 Subject: [PATCH] fix(publish): prune stored runtime assets of superseded page versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every full publish stores each page's bundled runtime files in published_runtime_assets under a fresh version path, and nothing ever deleted them. On a busy production site the table reached 206k rows / 1.5 GB (696 MB written in one day), bloating the database and its backups for bytes that are only a fallback behind the disk-baked copies. persistSitePublish now deletes, per page, the stored assets of every version other than the one being activated and the one it supersedes. Nothing re-activates an old version, and keeping the previous one covers visitors still holding its HTML — the same window the disk's two-slot swap covers. Version rows are untouched. The first publish after upgrading drops the backlog for the published pages. Co-Authored-By: Claude Opus 5.5 --- docs/features/publisher.md | 5 +- server/repositories/publish.ts | 32 +++++++ src/__tests__/server/cmsPublish.test.ts | 16 ++++ .../publishRuntimeAssetRetention.test.ts | 86 +++++++++++++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/server/publishRuntimeAssetRetention.test.ts diff --git a/docs/features/publisher.md b/docs/features/publisher.md index c31f99cf6..89fef0b68 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -320,7 +320,10 @@ The exclusive namespaces `/_instatic/css/*` (`serveSiteCss`) and `/_instatic/ass (`tryServeRuntimeAsset`) are served **disk-first**, falling back to a rebuild (`serveSiteCss`) or the DB (`published_runtime_assets`) only for preview or a publish whose disk write failed. Unknown paths under either prefix 404 rather -than falling through. +than falling through. Each full publish prunes the stored +`published_runtime_assets` of every page version except the one it activates +and the one it supersedes (`pruneSupersededRuntimeAssets` in +`server/repositories/publish.ts`); version rows themselves are kept. --- diff --git a/server/repositories/publish.ts b/server/repositories/publish.ts index a8a4cd31d..c813b87a2 100644 --- a/server/repositories/publish.ts +++ b/server/repositories/publish.ts @@ -260,6 +260,10 @@ export async function persistSitePublish( ) ` await savePublishedRuntimeAssets(tx, page.versionId, page.runtimeFiles) + const { rows: previousRows } = await tx<{ active_version_id: string | null }>` + select active_version_id from data_rows where id = ${page.pageId} + ` + const previousVersionId = previousRows[0]?.active_version_id ?? null const now = nowIso() const { rowCount } = await tx` update data_rows @@ -276,11 +280,39 @@ export async function persistSitePublish( // reaped it in between, don't leave an orphan version pointing at it. if (rowCount === 0) { await tx`delete from data_row_versions where id = ${page.versionId}` + continue } + await pruneSupersededRuntimeAssets(tx, page.pageId, page.versionId, previousVersionId) } }) } +/** + * Drop the stored runtime assets of a page's versions older than the one just + * superseded. Every full publish stores each page's bundled runtime files + * again under a fresh version path, and nothing re-activates an old version, + * so without this the table grew by the whole site's bundles on every publish + * (hundreds of MB a day on a busy site). The previous version's assets are + * kept so visitors still holding its HTML can load its scripts — the same + * window the disk's two-slot swap covers. The version rows themselves stay. + */ +async function pruneSupersededRuntimeAssets( + tx: DbClient, + pageId: string, + activeVersionId: string, + previousVersionId: string | null, +): Promise { + await tx` + delete from published_runtime_assets + where data_row_version_id in ( + select id from data_row_versions + where row_id = ${pageId} + and id <> ${activeVersionId} + and id <> ${previousVersionId ?? ''} + ) + ` +} + export async function getPublishedPageBySlug( db: DbClient, slug: string, diff --git a/src/__tests__/server/cmsPublish.test.ts b/src/__tests__/server/cmsPublish.test.ts index 3a94b3a89..3c2308b48 100644 --- a/src/__tests__/server/cmsPublish.test.ts +++ b/src/__tests__/server/cmsPublish.test.ts @@ -158,6 +158,22 @@ function createPublishFakeDb() { }) return { rows: [], rowCount: 1 } } + // persistSitePublish — read the page's previously active version + if (sql.startsWith('select active_version_id from data_rows where id =')) { + const row = state.dataRows.find((r) => r.id === params[0]) + return { rows: row ? [{ active_version_id: row.active_version_id }] : [], rowCount: row ? 1 : 0 } + } + // pruneSupersededRuntimeAssets — params: $1=rowId, $2=activeVersionId, $3=previousVersionId + if (sql.startsWith('delete from published_runtime_assets')) { + const pruned = new Set( + state.dataRowVersions + .filter((v) => v.row_id === params[0] && v.id !== params[1] && v.id !== params[2]) + .map((v) => v.id), + ) + const before = state.runtimeAssets.length + state.runtimeAssets = state.runtimeAssets.filter((a) => !pruned.has(a.page_version_id)) + return { rows: [], rowCount: before - state.runtimeAssets.length } + } // update data_rows set active_version_id = $1 ... (after publish) // SQL params: $1=versionId, $2=publishedByUserId, $3=published_at, $4=updatedByUserId, $5=updated_at, $6=rowId if (sql.startsWith('update data_rows') && sql.includes('active_version_id')) { diff --git a/src/__tests__/server/publishRuntimeAssetRetention.test.ts b/src/__tests__/server/publishRuntimeAssetRetention.test.ts new file mode 100644 index 000000000..02732a7af --- /dev/null +++ b/src/__tests__/server/publishRuntimeAssetRetention.test.ts @@ -0,0 +1,86 @@ +/** + * Every full publish stores each page's bundled runtime files under a fresh + * version path. Nothing re-activates an old version, so only the active and + * the just-superseded version keep their stored assets; older ones are pruned + * on publish. Without this the table grew by the site's whole bundle set on + * every publish (1.5 GB on one production site). + */ +import { describe, expect, it } from 'bun:test' +import { createTestDb } from '../helpers/createTestDb' +import { persistSitePublish } from '../../../server/repositories/publish' +import { createDataRow } from '../../../server/repositories/data/rows/mutations' +import { createUser } from '../../../server/repositories/users' +import { hashPassword } from '../../../server/auth/tokens' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import type { DbClient } from '../../../server/db' +import { makePage, makeSite } from '../fixtures' + +async function publishOnce(db: DbClient, pageIds: string[], n: number): Promise { + const site = makeSite({ pages: pageIds.map((id) => makePage({ id })) }) + const pages = pageIds.map((pageId) => { + const versionId = `${pageId}-v${n}` + return { + pageId, + title: pageId, + slug: pageId, + versionId, + versionNumber: n, + runtimeAssets: null, + runtimeFiles: ['entry.js', 'chunk.js'].map((name) => ({ + path: name, + publicPath: `/_instatic/assets/${versionId}/${name}`, + content: '', + bytes: new TextEncoder().encode(`// ${versionId} ${name}`), + contentType: 'text/javascript', + })), + } + }) + await persistSitePublish(db, { + siteSnapshotId: `snap-${n}`, + site, + serializedImportmap: null, + pages, + publishedByUserId: 'owner_1', + }) + return pages.map((page) => page.versionId) +} + +async function storedVersions(db: DbClient): Promise { + const { rows } = await db<{ data_row_version_id: string }>` + select distinct data_row_version_id from published_runtime_assets order by data_row_version_id + ` + return rows.map((row) => row.data_row_version_id) +} + +describe('published runtime asset retention', () => { + it('keeps only the active and previous version assets per page', async () => { + const { db, cleanup } = await createTestDb() + try { + await createUser(db, { + id: 'owner_1', + email: 'owner@example.com', + displayName: 'Owner', + passwordHash: await hashPassword('long-enough-password'), + roleId: 'owner', + allowOwnerRole: true, + }) + for (const id of ['home', 'about']) { + await createDataRow(db, MAIN_SCOPE, { id, tableId: 'pages', cells: { title: id }, slug: id }) + } + + await publishOnce(db, ['home', 'about'], 1) + await publishOnce(db, ['home', 'about'], 2) + expect(await storedVersions(db)).toEqual(['about-v1', 'about-v2', 'home-v1', 'home-v2']) + + await publishOnce(db, ['home', 'about'], 3) + await publishOnce(db, ['home', 'about'], 4) + expect(await storedVersions(db)).toEqual(['about-v3', 'about-v4', 'home-v3', 'home-v4']) + + // Version history itself is untouched. + const { rows } = await db<{ n: number }>`select count(*) as n from data_row_versions` + expect(Number(rows[0]!.n)).toBe(8) + } finally { + await cleanup() + } + }) +})