Skip to content
Open
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
5 changes: 4 additions & 1 deletion docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
32 changes: 32 additions & 0 deletions server/repositories/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void> {
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,
Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/server/cmsPublish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
86 changes: 86 additions & 0 deletions src/__tests__/server/publishRuntimeAssetRetention.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<string[]> {
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()
}
})
})
Loading