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
166 changes: 94 additions & 72 deletions server/utils/cdn-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,13 @@ export async function executeCDNBuild(options: BuildOptions): Promise<BuildResul
changedModelIds.push(...targetModels.map(m => m.id))

// A selective build whose diff touches no content models (a pure code
// push: only app/, server/, etc.) must be a true no-op. Uploading the
// manifest here would advance `_manifest.json.commitSha` to the code
// commit while the bundle block below is skipped (targetModels empty)
// leaving `_manifest.json.commitSha` ahead of every `_bundle/*.json`.
// Consumers that key content freshness off the manifest then read stale/
// empty content until a full rebuild re-aligns them. The manifest tracks
// the CONTENT version, so a content-less push must not bump it.
// push: only app/, server/, etc.) must be a true no-op. Running the rest
// would advance `_manifest.json.commitSha` to the code commit while the
// bundle block is skipped (targetModels empty), leaving the manifest ahead
// of every `_bundle/*.json`. Consumers that key content freshness off the
// manifest then read stale/empty content until a full rebuild re-aligns
// them (#153). The manifest tracks the CONTENT version, so a content-less
// push must not bump it — returning here also saves a wasted build cycle.
// fullRebuild (manual trigger) and config/model-def changes never reach
// here: the former skips the `else` branch above, the latter make
// getAffectedModels non-empty.
Expand All @@ -255,33 +255,7 @@ export async function executeCDNBuild(options: BuildOptions): Promise<BuildResul
}
}

// 4. Upload manifest
progress({ phase: 'upload', message: 'Uploading manifest...', current: 0, total: targetModels.length })
const manifest = {
version: '1',
commitSha,
builtAt: new Date().toISOString(),
branch,
config: {
stack: config.stack,
locales: config.locales,
domains: config.domains,
},
models: models.map(m => ({
id: m.id,
name: m.name,
kind: m.kind,
domain: m.domain,
i18n: m.i18n,
})),
}
const manifestData = JSON.stringify(manifest, null, 2)
await cdn.putObject(projectId, '_manifest.json', manifestData, 'application/json')
uploadedPaths.add('_manifest.json')
filesUploaded++
totalSizeBytes += Buffer.byteLength(manifestData)

// 5. Upload model index + definitions
// 4. Upload model index + definitions
const modelSummaries = models.map(m => ({
id: m.id,
name: m.name,
Expand All @@ -305,7 +279,7 @@ export async function executeCDNBuild(options: BuildOptions): Promise<BuildResul
totalSizeBytes += Buffer.byteLength(modelData)
}

// 6. Build content for each target model
// 5. Build content for each target model
let modelStep = 0
for (const model of targetModels) {
modelStep++
Expand Down Expand Up @@ -423,7 +397,7 @@ export async function executeCDNBuild(options: BuildOptions): Promise<BuildResul
}
}

// 6.5 Locale bundles — one conditional fetch replaces N per-model reads
// 6. Locale bundles — one conditional fetch replaces N per-model reads
// (SDK preload mode, docs/CDN_BUNDLE.md). Emitted on every build so the
// bundle always mirrors the standalone artifacts; skipped only when a
// selective build touched no models (content unchanged → bundles current).
Expand Down Expand Up @@ -477,7 +451,89 @@ export async function executeCDNBuild(options: BuildOptions): Promise<BuildResul
}
}

// 7. Diff-based stale object cleanup
// 7. Media manifest (if MediaProvider is available).
//
// Runs BEFORE the sweep on purpose. `_media_manifest.json` is written by the
// build, so it has to be in `uploadedPaths` by the time the full-rebuild
// sweep runs — otherwise the sweep deletes it and step 8 re-uploads it a
// moment later, leaving a window where the path 404s. The delivery SDK
// throws on any non-2xx and caches the media manifest for the lifetime of
// the instance, so a consumer that boots inside that window stays broken
// until it is replaced. Ordering it here also keeps the inverse correct: a
// project with no media assets uploads nothing, the path stays out of
// `uploadedPaths`, and the sweep garbage-collects a stale manifest.
try {
const mediaProvider = useMediaProvider()
if (mediaProvider) {
const { assets: mediaAssets } = await mediaProvider.listAssets(projectId, { limit: 10000 })
if (mediaAssets.length > 0) {
// Build media manifest
const mediaManifest: Record<string, { original: string, variants: Record<string, string>, meta: Record<string, unknown> }> = {}
for (const asset of mediaAssets) {
mediaManifest[asset.originalPath] = {
original: asset.originalPath,
variants: Object.fromEntries(Object.entries(asset.variants).map(([k, v]) => [k, v.path])),
meta: {
width: asset.width,
height: asset.height,
format: asset.format,
size: asset.size,
blurhash: asset.blurhash,
alt: asset.alt,
},
}
}
const mediaManifestData = JSON.stringify({ version: '1', assets: mediaManifest }, null, 2)
await cdn.putObject(projectId, '_media_manifest.json', mediaManifestData, 'application/json')
uploadedPaths.add('_media_manifest.json')
filesUploaded++
totalSizeBytes += Buffer.byteLength(mediaManifestData)
}
}
}
catch {
// Media manifest generation is non-fatal
}

// 8. Manifest — published LAST, after every artifact it describes.
//
// `_manifest.json` is the CONTENT VERSION POINTER: consumers key freshness
// off its commitSha. Publishing it first (as this used to) advertised a
// commit whose content, bundles and media manifest were still uploading —
// measured at ~105s on a full rebuild, since every object goes up one at a
// time. A consumer reading in that window pinned the new commitSha to
// pre-build bodies and, if it caches per commit, never re-read them. Same
// invariant #153 protected (manifest must not outrun the bundle); that fix
// covered a build that skipped the bundle, this covers every build that
// simply hadn't written it yet. A build that dies midway now leaves the old
// manifest pointing at the old, complete content instead of a half-written
// snapshot.
progress({ phase: 'upload', message: 'Uploading manifest...', current: targetModels.length, total: targetModels.length })
const manifest = {
version: '1',
commitSha,
builtAt: new Date().toISOString(),
branch,
config: {
stack: config.stack,
locales: config.locales,
domains: config.domains,
},
models: models.map(m => ({
id: m.id,
name: m.name,
kind: m.kind,
domain: m.domain,
i18n: m.i18n,
})),
}
const manifestData = JSON.stringify(manifest, null, 2)
await cdn.putObject(projectId, '_manifest.json', manifestData, 'application/json')
uploadedPaths.add('_manifest.json')
filesUploaded++
totalSizeBytes += Buffer.byteLength(manifestData)

// 9. Diff-based stale object cleanup
progress({ phase: 'cleanup', message: 'Cleaning stale objects...' })
try {
if (options.fullRebuild || !options.changedPaths?.length) {
Expand Down Expand Up @@ -520,41 +576,7 @@ export async function executeCDNBuild(options: BuildOptions): Promise<BuildResul
reportDataLossRisk(e, { op: 'cdn-build.cleanup', projectId, filesDeleted, fullRebuild: options.fullRebuild ?? false })
}

// 8. Media manifest (if MediaProvider is available)
try {
const mediaProvider = useMediaProvider()
if (mediaProvider) {
const { assets: mediaAssets } = await mediaProvider.listAssets(projectId, { limit: 10000 })
if (mediaAssets.length > 0) {
// Build media manifest
const mediaManifest: Record<string, { original: string, variants: Record<string, string>, meta: Record<string, unknown> }> = {}
for (const asset of mediaAssets) {
mediaManifest[asset.originalPath] = {
original: asset.originalPath,
variants: Object.fromEntries(Object.entries(asset.variants).map(([k, v]) => [k, v.path])),
meta: {
width: asset.width,
height: asset.height,
format: asset.format,
size: asset.size,
blurhash: asset.blurhash,
alt: asset.alt,
},
}
}
const manifestData = JSON.stringify({ version: '1', assets: mediaManifest }, null, 2)
await cdn.putObject(projectId, '_media_manifest.json', manifestData, 'application/json')
uploadedPaths.add('_media_manifest.json')
filesUploaded++
totalSizeBytes += Buffer.byteLength(manifestData)
}
}
}
catch {
// Media manifest generation is non-fatal
}

// 9. Purge edge cache
// 10. Purge edge cache
progress({ phase: 'done', message: `Build complete — ${filesUploaded} uploaded, ${filesDeleted} deleted`, current: targetModels.length, total: targetModels.length })
await cdn.purgeCache(projectId)

Expand Down
81 changes: 81 additions & 0 deletions tests/unit/cdn-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,87 @@ describe('cdn builder', () => {
expect(result.filesDeleted).toBe(2)
})

// `_manifest.json` is the content version pointer — consumers key freshness
// off its commitSha. It used to go up FIRST, so for the whole upload (~105s on
// a measured full rebuild, one object at a time) it advertised a commit whose
// content and bundles were still in flight. A consumer reading in that window
// pinned the new commitSha to pre-build bodies.
it('publishes _manifest.json after every artifact it describes', async () => {
const { git, provider } = seedProject('order-proj')

const result = await executeCDNBuild({
projectId: 'order-proj',
buildId: 'b',
git,
cdn: provider,
contentRoot: '',
commitSha: 's',
branch: 'main',
fullRebuild: true,
})

expect(result.error).toBeUndefined()
const written = vi.mocked(provider.putObject).mock.calls.map(c => c[1] as string)
expect(written).toContain('_manifest.json')
// Nothing the manifest points at may be written after it.
expect(written.at(-1)).toBe('_manifest.json')
expect(written.indexOf('_manifest.json')).toBeGreaterThan(written.indexOf('content/faq/en.json'))
expect(written.indexOf('_manifest.json')).toBeGreaterThan(written.indexOf('_bundle/en.json'))
})

// The sweep deletes every build-owned object outside uploadedPaths. The media
// manifest used to be written AFTER it, so each full rebuild deleted it and
// re-uploaded it a moment later — a window where the path 404s. The delivery
// SDK throws on non-2xx and caches the media manifest for the life of the
// instance, so a consumer booting inside that window stays broken.
it('never deletes _media_manifest.json during a full rebuild that has media', async () => {
const { git, provider, objects } = seedProject('media-proj')
objects.set('media-proj:_media_manifest.json', '{"version":"1","assets":{}}')
vi.stubGlobal('useMediaProvider', () => ({
listAssets: async () => ({
assets: [{ originalPath: 'media/original/keep.webp', variants: {}, width: 1, height: 1, format: 'webp', size: 3, blurhash: null, alt: null }],
}),
}))

const result = await executeCDNBuild({
projectId: 'media-proj',
buildId: 'b',
git,
cdn: provider,
contentRoot: '',
commitSha: 's',
branch: 'main',
fullRebuild: true,
})

expect(result.error).toBeUndefined()
const deleted = vi.mocked(provider.deleteObject).mock.calls.map(c => c[1] as string)
expect(deleted).not.toContain('_media_manifest.json')
expect(objects.has('media-proj:_media_manifest.json')).toBe(true)
})

// The inverse still has to hold: with no media assets nothing is uploaded, so
// a leftover manifest is genuinely stale and the sweep must collect it.
it('sweeps a stale _media_manifest.json when the project has no media assets', async () => {
const { git, provider, objects } = seedProject('nomedia-proj')
objects.set('nomedia-proj:_media_manifest.json', '{"version":"1","assets":{}}')
vi.stubGlobal('useMediaProvider', () => ({ listAssets: async () => ({ assets: [] }) }))

const result = await executeCDNBuild({
projectId: 'nomedia-proj',
buildId: 'b',
git,
cdn: provider,
contentRoot: '',
commitSha: 's',
branch: 'main',
fullRebuild: true,
})

expect(result.error).toBeUndefined()
expect(objects.has('nomedia-proj:_media_manifest.json')).toBe(false)
})

it('preserves media/* when a build runs with empty changedPaths (webhook empty-commits path)', async () => {
const { git, provider, objects } = seedProject('proj2')

Expand Down
Loading