diff --git a/apps/api/src/modules/build/build.service.ts b/apps/api/src/modules/build/build.service.ts index c4f650d..75f7607 100644 --- a/apps/api/src/modules/build/build.service.ts +++ b/apps/api/src/modules/build/build.service.ts @@ -6,12 +6,15 @@ // → succeeded;报告落 /jobs//reports/build.json(temp+rename // 原子写、**先于终态落行**——import 同款纪律)。 // -// 幂等:build_jobs.idempotency_key UNIQUE——重复提交同 key 复用既有 job 不重跑(含 -// 失败 job:重试须换 key——与 import_jobs 同纪律);并发同 key 由唯一约束收敛(插入 -// 撞 23505 → 重查复用)。默认幂等键 = site + **published 集合哈希** + 主题键@版本—— -// published 集合不变(发布后导入产新 current 不动 published,ADR-010)→ 同键复用; -// 重新 publish 指针前移 → 集合变 → 新 job。显式 idempotencyKey 可强制新物化(测试 -// 与操作者重跑通道)。 +// 幂等:build_jobs.idempotency_key UNIQUE——重复提交同 key:**只有 succeeded 复用** +// (产物完整);failed/cancelled/stale 终态死行与 queued/snapshotting/building/ +// validating 崩溃残留(非终态,旧行置 stale 宣判)都以 `#a` 后缀键 +// 建新行自动重跑(审查 Fix 3——import"换 key 手动重试"纪律的构建域演进:单进程就地 +// 推进下非终态滞留必为崩溃残留,自动恢复优于阻塞操作者);并发同 key 由唯一约束收敛 +// (插入撞 23505 → 回查推进)。默认幂等键 = site + **published 集合哈希** + 主题键@ +// 版本——published 集合不变(发布后导入产新 current 不动 published,ADR-010)→ 同键 +// 复用;重新 publish 指针前移 → 集合变 → 新 job。显式 idempotencyKey 可强制新物化 +// (测试与操作者重跑通道)。 // // 版本钉死(build_input_snapshots 的可复现性义务):node/pnpm 运行时真实探测 // (process.versions / npm_config_user_agent);theme/astro 是 **Task 3/5 前占位** @@ -85,7 +88,11 @@ export interface CreateBuildJobResult { jobId: string /** job 终态(或复用时既有状态) */ status: BuildJobStatus - /** true = 命中既有 job(幂等键复用,未重跑、未改行) */ + /** + * true = 命中既有 **succeeded** job(幂等键复用,未重跑、未改行——唯一可复用终态)。 + * false = 新建行:键空闲,或撞 failed/cancelled/stale(终态死行)/非终态崩溃残留 + * (旧行置 stale 宣判)——两种死行都以 `#a` 后缀键建新行重跑。 + */ reused: boolean siteKey: string siteId: string @@ -173,15 +180,21 @@ async function writeAtomically(path: string, content: string): Promise { } } -/** 既有 job 的复用返回(不动行、不重跑;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */ +/** 既有 job 的复用查询(不动行;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */ async function reuseExisting( db: BuildDb, idempotencyKey: string, -): Promise | null> { +): Promise<{ + jobId: string + status: BuildJobStatus + attempt: number + sourceHashes: { content: string; route: string; asset: string } +} | null> { const found = await db .select({ id: buildJobs.id, status: buildJobs.status, + attempt: buildJobs.attempt, contentSnapshotHash: buildInputSnapshots.contentSnapshotHash, routeSnapshotHash: buildInputSnapshots.routeSnapshotHash, assetManifestHash: buildInputSnapshots.assetManifestHash, @@ -197,6 +210,7 @@ async function reuseExisting( return { jobId: row.id, status: row.status, + attempt: row.attempt, sourceHashes: { content: row.contentSnapshotHash ?? '', route: row.routeSnapshotHash ?? '', @@ -205,12 +219,17 @@ async function reuseExisting( } } +/** 后缀键派生上界(守卫异常并发/病态键空间——正常重试谱系个位数即收敛) */ +const RETRY_KEY_BOUND = 100 + /** * 创建(并就地推进到底)一个 build job: - * 解析(site → published 检查 → config 检查)→ 幂等建行(queued)→ snapshotting + * 解析(site → published 检查 → config 检查)→ 幂等建行(queued;**只有 succeeded + * 可复用**——终态死行/崩溃残留按阶段 3 注记的策略自动恢复)→ snapshotting * (repeatable read 单事务:物化 + 六文件落盘 + build_input_snapshots 行)→ * validating(空——Task 6)→ 报告落盘 → succeeded。 * dist 产物与 build_artifacts 登记是 Task 5 职责(本任务 dist 尚空)。 + * 快照后失败 = job 行 failed 终态 + 半途 input/ 目录清场 + 原样上抛。 */ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): Promise { const startedAt = performance.now() @@ -258,67 +277,84 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P ) } - // 阶段 3:幂等建 job(queued)——命中既有 key 直接复用返回(不重跑) + // 阶段 3:幂等建 job(queued)——**只有 succeeded 可复用**(产物完整且报告在)。 + // 复用策略(审查 Fix 3 钉死): + // - succeeded → 原样复用返回(不重跑、不动行); + // - failed/cancelled/stale(终态)→ 不复用:以 `#a` 后缀键建新行 + // (attempt 延续记重试谱系); + // - queued/snapshotting/building/validating(非终态 = 崩溃残留——本服务单进程就地 + // 推进,库里滞留非终态即上个进程未走完)→ 旧行置 stale 宣判(finished_at 记宣判 + // 时刻)+ 同上后缀新行; + // - 后缀键自身再撞非 succeeded 行 → 同策略继续递增(上界 RETRY_KEY_BOUND 守卫); + // - 并发插行撞 23505 → 回循环头重查(对端 succeeded 则复用,否则按上述策略推进)。 + // 显式 idempotencyKey 同策略——操作者显式键撞死行同样自动换新行重跑。 const idempotencyKey = input.idempotencyKey ?? defaultBuildIdempotencyKey(site.key, publishedIds) - const existing = await reuseExisting(db, idempotencyKey) - if (existing !== null) { - const inputDir = join(artifactsRoot, 'jobs', existing.jobId, 'input') - return { - jobId: existing.jobId, - status: existing.status, - reused: true, - siteKey: site.key, - siteId: site.id, - inputDir, - reportPath: join(artifactsRoot, 'jobs', existing.jobId, 'reports', 'build.json'), - sourceHashes: existing.sourceHashes, - durationMs: performance.now() - startedAt, - } - } + let jobKey = idempotencyKey + let attempt = 1 let jobId: string | undefined - try { - const inserted = await db - .insert(buildJobs) - .values({ siteId: site.id, status: 'queued', idempotencyKey }) - .returning({ id: buildJobs.id }) - jobId = inserted[0]!.id - } catch (err) { - // 并发同 key:唯一约束收敛——重查复用(插入撞 23505 说明行已存在,重查必命中) - if (isUniqueViolation(err)) { - const raced = await reuseExisting(db, idempotencyKey) - if (raced !== null) { - return { - jobId: raced.jobId, - status: raced.status, - reused: true, - siteKey: site.key, - siteId: site.id, - inputDir: join(artifactsRoot, 'jobs', raced.jobId, 'input'), - reportPath: join(artifactsRoot, 'jobs', raced.jobId, 'reports', 'build.json'), - sourceHashes: raced.sourceHashes, - durationMs: performance.now() - startedAt, - } + for (let round = 0; ; round++) { + if (round > RETRY_KEY_BOUND) { + throw new Error(`幂等键重试派生超界(${idempotencyKey}#a… > ${RETRY_KEY_BOUND})——疑似异常并发`) + } + const found = await reuseExisting(db, jobKey) + if (found !== null && found.status === 'succeeded') { + return { + jobId: found.jobId, + status: found.status, + reused: true, + siteKey: site.key, + siteId: site.id, + inputDir: join(artifactsRoot, 'jobs', found.jobId, 'input'), + reportPath: join(artifactsRoot, 'jobs', found.jobId, 'reports', 'build.json'), + sourceHashes: found.sourceHashes, + durationMs: performance.now() - startedAt, } } - throw err + if (found !== null) { + if (found.status !== 'failed' && found.status !== 'cancelled' && found.status !== 'stale') { + await db + .update(buildJobs) + .set({ status: 'stale', finishedAt: new Date() }) + .where(eq(buildJobs.id, found.jobId)) + } + attempt = found.attempt + 1 + jobKey = `${idempotencyKey}#a${attempt}` + continue + } + try { + const inserted = await db + .insert(buildJobs) + .values({ siteId: site.id, status: 'queued', idempotencyKey: jobKey }) + .returning({ id: buildJobs.id }) + jobId = inserted[0]!.id + break + } catch (err) { + // 并发同 key:唯一约束收敛 → 回循环头重查(23505 = 行已存在) + if (isUniqueViolation(err)) continue + throw err + } } const inputDir = join(artifactsRoot, 'jobs', jobId, 'input') const reportDir = join(artifactsRoot, 'jobs', jobId, 'reports') const reportPath = join(reportDir, 'build.json') input.onStatus?.('queued', jobId) - // 失败收口:job 行保留(failed 终态 + error 面)后原样上抛——镜像 import 纪律 + // 失败收口:job 行保留(failed 终态 + error 面 + input/ 目录清场)后原样上抛——镜像 import 纪律 try { - // 阶段 4:snapshotting(attempt=1、本地 worker 就地推进) + // 阶段 4:snapshotting(attempt 延续重试谱系:新建 1 / 撞死行重跑 prev+1;本地 worker 就地推进) await db .update(buildJobs) - .set({ status: 'snapshotting', startedAt: new Date(), attempt: 1, workerId: 'local' }) + .set({ status: 'snapshotting', startedAt: new Date(), attempt, workerId: 'local' }) .where(eq(buildJobs.id, jobId)) input.onStatus?.('snapshotting', jobId) // 阶段 5:repeatable read 单事务——一致性读取(published 集/路由/资产同一时点)+ - // 六文件落盘 + build_input_snapshots 行(读取面与登记面原子:行在即文件内容可复算) + // 六文件落盘 + build_input_snapshots 行(读取面与登记面原子:行在即文件内容可复算)。 + // 版本/环境探测在事务外单次完成(审查 Fix 4:git rev-parse 是进程派生,不进事务窗口) const buildTimestamp = new Date() + const sourceCommit = detectSourceCommit() + const pnpmVersion = detectPnpmVersion() + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' const mat = await db.transaction( async (tx) => { const m = await materializeRenderSnapshot(tx, { siteId: site.id, generatedAt: buildTimestamp.toISOString() }) @@ -337,12 +373,12 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P themeVersion: THEME_VERSION, themeSourceHash: THEME_SOURCE_HASH_PLACEHOLDER, dependencyLockHash: DEPENDENCY_LOCK_HASH_PLACEHOLDER, - sourceCommit: detectSourceCommit(), + sourceCommit, nodeVersion: process.versions.node, - pnpmVersion: detectPnpmVersion(), + pnpmVersion, astroVersion: ASTRO_VERSION_PLACEHOLDER, locale: site.defaultLocale, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + timezone, buildTimestamp, manifestPath: join(inputDir, 'render-manifest.json'), }) @@ -362,7 +398,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P status: 'succeeded', siteKey: site.key, siteId: site.id, - idempotencyKey, + idempotencyKey: jobKey, theme: { key: THEME_KEY, version: THEME_VERSION }, themePlaceholders: { themeSourceHash: true, @@ -371,10 +407,10 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P }, buildEnv: { node: process.versions.node, - pnpm: detectPnpmVersion(), - sourceCommit: detectSourceCommit(), + pnpm: pnpmVersion, + sourceCommit, locale: site.defaultLocale, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + timezone, }, snapshot: { siteConfigRevisionId: mat.siteConfigRevisionId, @@ -427,6 +463,15 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P .update(buildJobs) .set({ status: 'failed', finishedAt: new Date(), errorCode: code, errorSummary: `[build] ${summary}` }) .where(eq(buildJobs.id, jobId)) + // 失败清场(审查 Fix 3):半途落盘的 input/ 目录移除——failed 终态无产物语义, + // 不留"行说 failed 而目录半存"的歧义现场。快照行若已插入则由不可变触发器保留为 + // 审计记录(manifest_path 悬空可接受:job 已 failed,无下游消费方)。清场失败 + // 不掩盖原始错误(残留目录由操作者处置)。 + try { + await rm(inputDir, { recursive: true, force: true }) + } catch { + // best-effort:保留原错误上抛 + } throw err } } diff --git a/apps/api/src/modules/build/render-snapshot.ts b/apps/api/src/modules/build/render-snapshot.ts index fd85b82..d7ac50f 100644 --- a/apps/api/src/modules/build/render-snapshot.ts +++ b/apps/api/src/modules/build/render-snapshot.ts @@ -15,8 +15,9 @@ // data:/纯片段/相对形态无路径可比对,原样越过不计(与 media-writer 的 data: 口径一致)。 // // canonical JSON 确定性:六文件全部以 canonicalJson(键递归字典序、无空白)+ 换行落盘 -// ——文件字节即哈希输入(content/route/asset 三哈希 = 各自 canonicalJson 的 sha256), -// 同输入(含 generatedAt 时点参数)两次物化字节一致。集合排序在 JS 侧钉死(UTF-16 +// ——文件字节(含尾换行)即哈希输入(content/route/asset 三哈希 = 各自**文件字节**的 +// sha256——哈希链与落盘文件互证),同输入(含 generatedAt 时点参数)两次物化字节一致。 +// 集合排序在 JS 侧钉死(UTF-16 // 序,不依赖 PG collation):内容按 (typeKey, slug, locale, contentId)、分类按 (key, // objectType)、term 按 (sourceId, slug, id)、路由按 (routePath, queryKey nulls-first, // host)、资产按 publicPath。 @@ -29,7 +30,7 @@ import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { and, desc, eq, inArray, isNotNull } from 'drizzle-orm' +import { and, desc, eq, inArray, isNotNull, ne } from 'drizzle-orm' import type { NodePgDatabase } from 'drizzle-orm/node-postgres' import { ADAPTER_VERSION, @@ -121,12 +122,15 @@ export interface RewriteStats { /** * bodyHtml 的 src/href URL 重写(正则提取——清洗信任链上游已保证属性形态规整; * 门禁期的 HTML 解析复用清洗管线是 Task 6 职责,此处不引入解析器依赖)。 + * 属性名边界锚定(审查 Fix 5):前缀必须是空白或串首——`data-src`/`data-href` + * 等连字属性不匹配不卷入(\b 会把 data-src 的 '-s' 交界误判为词边界;懒加载 + * 属性的改写是渲染域决策,不是快照域义务)。 * 只替换属性值本身,引号/空白原样保留;命中值与原值相等时计数但零改写。 */ export function rewriteBodyHtml(html: string, index: Map): { html: string; stats: RewriteStats } { const stats: RewriteStats = { rewritten: 0, unresolved: 0 } - const ATTR_RE = /\b(src|href)\s*=\s*(["'])([^"']*)\2/gi - const out = html.replace(ATTR_RE, (full, _attr: string, quote: string, value: string) => { + const ATTR_RE = /(^|\s)(src|href)\s*=\s*(["'])([^"']*)\3/gi + const out = html.replace(ATTR_RE, (full, _prefix: string, _attr: string, quote: string, value: string) => { const path = comparablePathOf(value) if (path === null) return full const hit = index.get(path) @@ -184,9 +188,9 @@ function cmpNull(a: string | null, b: string | null): number { return cmp(a, b) } -/** sha256 of canonicalJson(三哈希的单一算法来源) */ +/** 三哈希的单一算法来源:sha256(文件字节)——canonicalJson + 尾换行后散列(与落盘文件互证) */ function hashOf(payload: unknown): string { - return createHash('sha256').update(canonicalJson(payload)).digest('hex') + return createHash('sha256').update(`${canonicalJson(payload)}\n`).digest('hex') } /** 仓库存储根的 incoming 媒体兜底目录(真实数据:storage/incoming/<目录>/<扁平 GUID 文件>) */ @@ -258,7 +262,11 @@ export async function materializeRenderSnapshot( assetRows.sort((a, b) => cmp(a.publicPath, b.publicPath)) const mediaIndex = buildMediaPathIndex(assetRows) - // published 修订的 media_usages 引用面(fieldPath → public_path;content.json 的 media 块) + // published 修订的 media_usages 引用面(fieldPath → public_path;content.json 的 media 块)。 + // 确定性(审查 Fix 2):fieldPath/publicPath 双键 orderBy 钉死 first-wins 的"首"—— + // 同 fieldPath 多资产引用(PK 是 资产×修订×fieldPath,跨资产可重)时取字典序首位, + // 其余丢弃并记 issue(§7.6 禁静默);只 join ready 资产(media 块只呈现可服务引用, + // 审查 Fix 6)——非 ready 资产的残留引用单独查出版 issue,不进块。 const publishedIds = published.map((r) => r.revisionId!) const usageMap = new Map>() if (publishedIds.length > 0) { @@ -270,14 +278,50 @@ export async function materializeRenderSnapshot( }) .from(mediaUsages) .innerJoin(mediaAssets, eq(mediaUsages.mediaAssetId, mediaAssets.id)) - .where(and(eq(mediaUsages.siteId, input.siteId), inArray(mediaUsages.contentRevisionId, publishedIds))) + .where( + and( + eq(mediaUsages.siteId, input.siteId), + inArray(mediaUsages.contentRevisionId, publishedIds), + eq(mediaAssets.status, 'ready'), + ), + ) + .orderBy(mediaUsages.fieldPath, mediaAssets.publicPath) for (const u of usages) { let byField = usageMap.get(u.contentRevisionId) if (byField === undefined) { byField = new Map() usageMap.set(u.contentRevisionId, byField) } - byField.set(u.fieldPath, u.publicPath) + const first = byField.get(u.fieldPath) + if (first === undefined) { + byField.set(u.fieldPath, u.publicPath) + } else if (first !== u.publicPath) { + issues.push( + `同 fieldPath 多资产引用(revision=${u.contentRevisionId} fieldPath=${u.fieldPath}):first-wins 取 ${first},丢弃 ${u.publicPath}`, + ) + } + } + // 非 ready 资产的残留引用(登记后状态翻转等数据面事实):不进 media 块,如实记 issue + const nonReady = await db + .select({ + contentRevisionId: mediaUsages.contentRevisionId, + fieldPath: mediaUsages.fieldPath, + publicPath: mediaAssets.publicPath, + status: mediaAssets.status, + }) + .from(mediaUsages) + .innerJoin(mediaAssets, eq(mediaUsages.mediaAssetId, mediaAssets.id)) + .where( + and( + eq(mediaUsages.siteId, input.siteId), + inArray(mediaUsages.contentRevisionId, publishedIds), + ne(mediaAssets.status, 'ready'), + ), + ) + for (const u of nonReady) { + issues.push( + `media 引用非 ready 资产(revision=${u.contentRevisionId} fieldPath=${u.fieldPath} public_path=${u.publicPath} status=${u.status})——该引用不进 content.json media 块`, + ) } } diff --git a/tests/integration/build-snapshot.test.ts b/tests/integration/build-snapshot.test.ts index cc36823..645bc1f 100644 --- a/tests/integration/build-snapshot.test.ts +++ b/tests/integration/build-snapshot.test.ts @@ -2,9 +2,11 @@ // 覆盖契约(对照计划 Task 2): // 1. build job 状态机:queued→snapshotting→validating(空,Task 6 填)→succeeded(dist 尚空 // ——Task 5 接真实构建时新建 job 不改行,build_input_snapshots 有不可变触发器)+ -// build_input_snapshots 行(content/route/asset 三哈希 = 各自 canonicalJson 的 sha256、 -// site_config_revision_id 指向 config v1、theme_key='pilot-minimal'、node_version 等版本 -// 信息列真实读 process.versions)+ 幂等键复用(同 published 集合 → reused 不重跑); +// build_input_snapshots 行(content/route/asset 三哈希 = 各自**文件字节**(canonicalJson+ +// 尾换行)的 sha256——哈希链与落盘文件互证、site_config_revision_id 指向 config v1、 +// theme_key='pilot-minimal'、node_version 等版本信息列真实读 process.versions)+ +// 幂等键复用(同 published 集合 → reused 不重跑;只有 succeeded 可复用——failed/ +// 崩溃残留行走 stale 宣判+#a 后缀新行自动恢复); // 2. 六文件物化(artifacts/jobs//input/):site.json(config v1)/content.json // (published 修订数组,data 全量 + media 引用面,slug 原样)/taxonomies.json(双源全量, // source_id 原样含 auto:)/routes.json(html+asset+redirect 并入,query_key/ @@ -38,6 +40,7 @@ import { registerSource } from '../../apps/api/src/modules/source/register.servi import { BuildError, createBuildJob, + defaultBuildIdempotencyKey, } from '../../apps/api/src/modules/build/build.service.js' import { buildMediaPathIndex, @@ -185,14 +188,14 @@ describe('build job + Render Snapshot 物化', () => { expect(s.content_snapshot_hash).toBe(manifest.sourceHashes.content) expect(s.route_snapshot_hash).toBe(manifest.sourceHashes.route) expect(s.asset_manifest_hash).toBe(manifest.sourceHashes.asset) - // 三哈希独立可复算:各文件 canonicalJson 的 sha256(测试独立重算对拍) + // 三哈希独立可复算:sha256(文件字节含尾换行)——哈希链与落盘文件互证(审查 Fix 1) for (const [col, file] of [ ['content_snapshot_hash', 'content.json'], ['route_snapshot_hash', 'routes.json'], ['asset_manifest_hash', 'assets.json'], ] as const) { - const payload = JSON.parse(await readFile(join(result.inputDir, file), 'utf8')) - expect(s[col]).toBe(sha256of(canonicalJson(payload))) + const raw = await readFile(join(result.inputDir, file), 'utf8') + expect(s[col]).toBe(sha256of(raw)) } const config = (await db.execute(`select scr.id from site_config_revisions scr where scr.site_id = '${siteId}'`)) as any @@ -537,6 +540,168 @@ describe('build job + Render Snapshot 物化', () => { } }) }) + + it('media 块确定性:同 fieldPath 多资产引用 → first-wins 单值 + issue;两次物化字节一致(审查 Fix 2)', async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-build-dup') + try { + const { siteId } = await seedPublishedSite(db, w) + try { + const aPub = '/wp-content/uploads/2026/10/fixture-a.png' + const bPub = '/wp-content/uploads/2026/10/fixture-b.png' + // 数据面异常注入:news published 修订的 featured 字段再挂 fixture-b + //(media_usages PK = 资产×修订×fieldPath——跨资产同 fieldPath 可重) + await db.execute(`insert into media_usages (site_id, media_asset_id, content_revision_id, field_path, source_url) + select '${siteId}', + (select id from media_assets where site_id = '${siteId}' and public_path = '${bPub}'), + (select el.published_revision_id from entry_locales el + join content_entries ce on ce.id = el.content_entry_id + where el.site_id = '${siteId}' and ce.type_key = 'news_article'), + 'attachmentRefs.featured', + 'https://fixture.example/wp-content/uploads/2026/10/fixture-b.png'`) + const fixedAt = '2026-10-22T00:00:00.000Z' + const m1 = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) + const m2 = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) + const content1 = JSON.parse(m1.files['content.json']) + const news = content1.find((c: any) => c.slug === 'fixture-news-a') + // first-wins:orderBy(fieldPath, publicPath) 字典序首位 = fixture-a 稳定胜出 + expect(news.media['attachmentRefs.featured']).toBe(aPub) + expect( + m1.issues.some((i) => i.includes('同 fieldPath 多资产引用') && i.includes('attachmentRefs.featured')), + ).toBe(true) + // 两次物化字节一致 + issue 面一致(first-wins 确定性) + expect(m2.files['content.json']).toBe(m1.files['content.json']) + expect(m2.issues).toStrictEqual(m1.issues) + } finally { + await cleanupSiteRows(db, siteId) + } + } finally { + await rm(w.work, { recursive: true, force: true }) + } + }) + }) + + it('job 复用只认 succeeded:failed job + 默认键重跑 → #a2 新行成功、旧行保持 failed(审查 Fix 3)', async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-build-failed') + try { + const { key, siteId } = await seedPublishedSite(db, w) + try { + // 迫使快照落盘失败:artifactsRoot 指到普通文件之下 → mkdir ENOTDIR + const blocker = join(w.work, 'blocker') + await writeFile(blocker, 'x', 'utf8') + const err: unknown = await createBuildJob(db, { + siteKey: key, + artifactsRoot: join(blocker, 'artifacts'), + }).then( + () => { + throw new Error('不应成功') + }, + (e: unknown) => e, + ) + expect(err).toBeInstanceOf(Error) + const afterFail = plain( + (await db.execute(`select status, error_code, attempt from build_jobs + where site_id = '${siteId}'`)) as any, + ) + expect(afterFail).toHaveLength(1) + expect(afterFail[0].status).toBe('failed') + expect(afterFail[0].error_code).toBe('SNAPSHOT_FAILED') + expect(Number(afterFail[0].attempt)).toBe(1) + + // 默认键重跑(好根):不复用 failed 行 → 后缀键新行成功 + const rerun = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') }) + expect(rerun.reused).toBe(false) + expect(rerun.status).toBe('succeeded') + expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${siteId}'`)).toBe(2) + // 旧行保持 failed;新行键带 #a2 后缀、attempt 延续 = 2(重试谱系) + const rows = plain( + (await db.execute(`select status, idempotency_key, attempt from build_jobs + where site_id = '${siteId}' order by attempt`)) as any, + ) + expect(rows[0].status).toBe('failed') + expect(rows[1].status).toBe('succeeded') + expect(String(rows[1].idempotency_key)).toContain('#a2') + expect(Number(rows[1].attempt)).toBe(2) + } finally { + await cleanupSiteRows(db, siteId) + } + } finally { + await rm(w.work, { recursive: true, force: true }) + } + }) + }) + + it('崩溃残留回收:滞留 snapshotting 的默认键行 → stale 宣判 + 新 job 物化成功(审查 Fix 3)', async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-build-stale') + try { + const { key, siteId } = await seedPublishedSite(db, w) + try { + // 手构崩溃残留:上个进程中断留下的 snapshotting 悬行(默认键、attempt=1) + const pubIds = plain( + (await db.execute(`select published_revision_id from entry_locales + where site_id = '${siteId}' and published_revision_id is not null`)) as any, + ).map((r: any) => r.published_revision_id) + const defKey = defaultBuildIdempotencyKey(key, pubIds) + await db.execute(`insert into build_jobs (site_id, status, idempotency_key, attempt, started_at) + values ('${siteId}', 'snapshotting', '${defKey}', 1, now())`) + + const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') }) + expect(result.reused).toBe(false) + expect(result.status).toBe('succeeded') + // 旧行宣判 stale;新行成功——两行并存(键空间让位) + const rows = plain( + (await db.execute(`select status, idempotency_key from build_jobs + where site_id = '${siteId}' order by attempt`)) as any, + ) + expect(rows).toHaveLength(2) + expect(rows[0].status).toBe('stale') + expect(rows[0].idempotency_key).toBe(defKey) + expect(rows[1].status).toBe('succeeded') + expect(rows[1].idempotency_key).toBe(`${defKey}#a2`) + // 新 job 物化完整 + expect(existsSync(join(result.inputDir, 'render-manifest.json'))).toBe(true) + } finally { + await cleanupSiteRows(db, siteId) + } + } finally { + await rm(w.work, { recursive: true, force: true }) + } + }) + }) + + it('media 引用非 ready 资产:引用不进 media 块 + issue 如实报告;assets 面同步收窄(审查 Fix 6)', async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-build-nonready') + try { + const { siteId } = await seedPublishedSite(db, w) + try { + // 登记后状态翻转:fixture-b(product 的 featured 引用)→ quarantined + await db.execute(`update media_assets set status = 'quarantined' + where site_id = '${siteId}' and public_path = '/wp-content/uploads/2026/10/fixture-b.png'`) + const m = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' }) + const content = JSON.parse(m.files['content.json']) + const product = content.find((c: any) => c.slug === 'fixture-product') + // 引用不进 media 块(product 的 featured 是 fixture-b)+ issue 如实报告 + expect(product.media).toBeUndefined() + expect( + m.issues.some( + (i) => i.includes('非 ready') && i.includes('attachmentRefs.featured') && i.includes('quarantined'), + ), + ).toBe(true) + // ready 集合同步收窄:assets 面只剩 fixture-a + const assets = JSON.parse(m.files['assets.json']) + expect(assets).toHaveLength(1) + expect(assets[0].publicPath).toBe('/wp-content/uploads/2026/10/fixture-a.png') + } finally { + await cleanupSiteRows(db, siteId) + } + } finally { + await rm(w.work, { recursive: true, force: true }) + } + }) + }) }) describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双源)', () => { @@ -570,7 +735,7 @@ describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双 expect(r.stats.rewritten).toBe(1) }) - it('未命中(含外域)保留原样 + unresolved 计数;data:/片段/相对形态不计', () => { + it('未命中(含外域)保留原样 + unresolved 计数;data:/片段/相对形态不计;连字属性不卷入(审查 Fix 5)', () => { const r = rewriteBodyHtml( 'ptr', index, @@ -579,5 +744,10 @@ describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双 'ptr', ) expect(r.stats).toStrictEqual({ rewritten: 0, unresolved: 1 }) + // 边界锚定:data-src 的 URL 即使命中索引也不改写不计(懒加载属性是渲染域决策) + //(宽松 \b 前缀会把 data-src 的 '-s' 交界误判为词边界——此用例钉死收紧语义) + const lazy = rewriteBodyHtml(``, index) + expect(lazy.html).toBe(``) + expect(lazy.stats).toStrictEqual({ rewritten: 0, unresolved: 0 }) }) })