fix(build): 哈希链含尾换行;media 块 first-wins 确定性;job 复用只认 succeeded+崩溃回收;重写器边界锚定
审查六修(Fix 2/3 先于 Task 3 消费六文件落形): - Fix 1 三哈希 = sha256(文件字节含尾换行)——哈希链与落盘文件互证;测试改按 原始文件字节独立复算对拍 - Fix 2 media_usages orderBy(fieldPath, publicPath) + first-wins:同 fieldPath 多资产引用取字典序首位、其余丢弃记 issue;两次物化字节一致有测试钉死 - Fix 3 job 复用只认 succeeded;failed/cancelled/stale 死行与非终态崩溃残留 (旧行置 stale 宣判)都以 <key>#a<attempt+1> 后缀键建新行自动重跑(attempt 延续谱系);失败终态化时清走半途 input/ 目录;两路径各有测试 - Fix 4 git rev-parse 等环境探测移出快照事务(单次探测传入) - Fix 5 属性正则边界锚定 (^|\s):data-src 等连字属性不卷入(\b 会误判 '-s' 交界)——data-src 命中索引也不改写的用例钉死 - Fix 6 media 引用面只 join ready 资产;非 ready 残留引用单独查出记 issue、 不进 content.json media 块(quarantined 翻转测试) 354/354(350+4);typecheck 四段、lint 全绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,15 @@
|
||||
// → succeeded;报告落 <artifactsRoot>/jobs/<jobId>/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 宣判)都以 `<key>#a<attempt+1>` 后缀键
|
||||
// 建新行自动重跑(审查 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 宣判)——两种死行都以 `<key>#a<attempt+1>` 后缀键建新行重跑。
|
||||
*/
|
||||
reused: boolean
|
||||
siteKey: string
|
||||
siteId: string
|
||||
@@ -173,15 +180,21 @@ async function writeAtomically(path: string, content: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 既有 job 的复用返回(不动行、不重跑;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */
|
||||
/** 既有 job 的复用查询(不动行;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */
|
||||
async function reuseExisting(
|
||||
db: BuildDb,
|
||||
idempotencyKey: string,
|
||||
): Promise<Pick<CreateBuildJobResult, 'jobId' | 'status' | 'sourceHashes'> | 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<CreateBuildJobResult> {
|
||||
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(终态)→ 不复用:以 `<key>#a<attempt+1>` 后缀键建新行
|
||||
// (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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>): { 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<string, Map<string, string>>()
|
||||
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 块`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<jobId>/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(
|
||||
'<a href="https://cdn.other.example.com/pixel.png">p</a><img src="data:image/png;base64,AAA"/><a href="#top">t</a><a href="page.html">r</a>',
|
||||
index,
|
||||
@@ -579,5 +744,10 @@ describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双
|
||||
'<a href="https://cdn.other.example.com/pixel.png">p</a><img src="data:image/png;base64,AAA"/><a href="#top">t</a><a href="page.html">r</a>',
|
||||
)
|
||||
expect(r.stats).toStrictEqual({ rewritten: 0, unresolved: 1 })
|
||||
// 边界锚定:data-src 的 URL 即使命中索引也不改写不计(懒加载属性是渲染域决策)
|
||||
//(宽松 \b 前缀会把 data-src 的 '-s' 交界误判为词边界——此用例钉死收紧语义)
|
||||
const lazy = rewriteBodyHtml(`<img data-src="https://old.example.com${A}"/>`, index)
|
||||
expect(lazy.html).toBe(`<img data-src="https://old.example.com${A}"/>`)
|
||||
expect(lazy.stats).toStrictEqual({ rewritten: 0, unresolved: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user