From 8f42c838c1a47d9ef1ca2ec70f5d0bf1cda93032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 22 Oct 2026 07:46:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(build):=20=E5=86=BB=E7=BB=93=20Render=20Sn?= =?UTF-8?q?apshot=20=E7=89=A9=E5=8C=96=E4=B8=8E=20build=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 4 Task 2:build_jobs 生命周期(queued→snapshotting→validating(空)→succeeded) + build_input_snapshots 行(三哈希/主题/config 引用/真实版本列)+ 六文件物化 (site/content/taxonomies/routes/assets/render-manifest——canonicalJson 确定性落盘)。 - render-snapshot.ts:纯物化(db 只读)——published 修订(ADR-010 冻结语义)+ 路由/媒体 ready 集合 → 六文件;bodyHtml URL 重写(剥 host 按 path 与 ready 集合 public_path/source_url 双源比对,命中根相对化、未命中保留+计数);媒体实体路径 三候选解析(快照 storageRoot→仓库存储根→incoming 兜底) - build.service.ts:幂等键 = site+published 集合哈希+主题版本(复用不重跑); repeatable read 单事务一致性读取+落盘+快照行;报告 artifacts/jobs//reports/ build.json 先于终态落行;解析期失败零 job 行(NOT_PUBLISHED/SITE_NOT_FOUND) - sf.ts + build:create;build.module.ts Nest 薄壳 - tests/integration/build-snapshot.test.ts:10 测试(状态机/六文件面/URL 重写/ 字节确定性/NOT_PUBLISHED/ADR-010 双修订/重写器纯函数四例) Co-Authored-By: Claude Code --- apps/api/src/modules/build/build.module.ts | 28 + apps/api/src/modules/build/build.service.ts | 432 +++++++++++++ apps/api/src/modules/build/render-snapshot.ts | 479 ++++++++++++++ scripts/sf.ts | 59 +- tests/integration/build-snapshot.test.ts | 583 ++++++++++++++++++ 5 files changed, 1575 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/modules/build/build.module.ts create mode 100644 apps/api/src/modules/build/build.service.ts create mode 100644 apps/api/src/modules/build/render-snapshot.ts create mode 100644 tests/integration/build-snapshot.test.ts diff --git a/apps/api/src/modules/build/build.module.ts b/apps/api/src/modules/build/build.module.ts new file mode 100644 index 0000000..e08a403 --- /dev/null +++ b/apps/api/src/modules/build/build.module.ts @@ -0,0 +1,28 @@ +// build 域 Nest 模块(Plan 4 Task 2):注册 BuildJobService 供 standalone context +// 使用(NestFactory.createApplicationContext(BuildModule)——本切片 API 不监听端口,ADR-002)。 +// 与 source.module.ts / import.module.ts / publish.module.ts 同构薄壳:逻辑全在 +// build.service.ts 的零装饰器独立函数里,本类只提供 DI 语境下的调用面; +// 'reflect-metadata' 置顶(CLI/测试直接 import build.service.ts 纯函数文件, +// 不经本模块,故无需提前加载 reflect-metadata)。 +import 'reflect-metadata' +import { Injectable, Module } from '@nestjs/common' +import { + createBuildJob, + type BuildDb, + type CreateBuildJobInput, + type CreateBuildJobResult, +} from './build.service.js' + +/** createBuildJob 的 Nest 注入壳(standalone context / 后续 REST 通道复用同一实现) */ +@Injectable() +export class BuildJobService { + create(db: BuildDb, input: CreateBuildJobInput): Promise { + return createBuildJob(db, input) + } +} + +@Module({ + providers: [BuildJobService], + exports: [BuildJobService], +}) +export class BuildModule {} diff --git a/apps/api/src/modules/build/build.service.ts b/apps/api/src/modules/build/build.service.ts new file mode 100644 index 0000000..c4f650d --- /dev/null +++ b/apps/api/src/modules/build/build.service.ts @@ -0,0 +1,432 @@ +// build_jobs 生命周期服务(Plan 4 Task 2,V2 spec §9、ADR-010/ADR-011): +// 解析站点 → publish 状态检查(快照只读 published——空发布集无物化意义)→ 幂等建 +// job(queued)→ snapshotting(render-snapshot.ts 纯物化六文件 + build_input_snapshots +// 行,repeatable read 单事务一致性读取)→ validating(**空门禁**——Task 6 填真实 +// 门禁;Task 5 接真实构建时**新建 job 不改行**:build_input_snapshots 有不可变触发器) +// → 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_input_snapshots 的可复现性义务):node/pnpm 运行时真实探测 +// (process.versions / npm_config_user_agent);theme/astro 是 **Task 3/5 前占位** +// (theme.manifest 落地后由 manifest 供给——本任务先钉键形与列面,占位值常量单源)。 +// +// 失败口径:解析阶段失败(SITE_NOT_FOUND/NOT_PUBLISHED/NO_SITE_CONFIG)零 job 行; +// 快照/落盘阶段失败 job 行保留(failed 终态 + error_code/error_summary)后原样上抛。 +// +// 本文件与 register/import/publish 服务同纪律保持 **零装饰器**:CLI(scripts/sf.ts +// 经 tsx)与集成测试直接 import 本文件;Nest 侧由 build.module.ts 薄包装。 +import { createHash } from 'node:crypto' +import { execSync } from 'node:child_process' +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { desc, eq } from 'drizzle-orm' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { canonicalJson } from '../../common/canonical.js' +import { buildInputSnapshots, buildJobs, entryLocales, siteConfigRevisions, sites } from '../../db/schema.js' +import type * as schema from '../../db/schema.js' +import { isUniqueViolation } from '../source/register.service.js' +import { materializeRenderSnapshot, type RewriteStats } from './render-snapshot.js' + +/** 本文件位于 /apps/api/src/modules/build/ → 上五级是仓库根 */ +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..') + +export type BuildDb = NodePgDatabase + +export type BuildJobStatus = (typeof schema.buildJobStatus.enumValues)[number] + +export type BuildErrorCode = + | 'SITE_NOT_FOUND' + | 'NOT_PUBLISHED' + | 'NO_SITE_CONFIG' + | 'SNAPSHOT_FAILED' + | 'REPORT_WRITE_FAILED' + +/** 构建失败(可预期错误):CLI 以 code 区分呈现;不裹程序 bug(那些原样上抛) */ +export class BuildError extends Error { + readonly code: BuildErrorCode + constructor(code: BuildErrorCode, message: string) { + super(message) + this.name = 'BuildError' + this.code = code + } +} + +/** + * 主题钉死(Plan 4 唯一主题):theme_key 落 build_input_snapshots 与报告。 + * THEME_VERSION 是 Task 3 前占位——theme.manifest 落地后由 manifest 供给真值。 + */ +export const THEME_KEY = 'pilot-minimal' +export const THEME_VERSION = '0.0.0' + +/** Task 3/5 前占位(列 NOT NULL 先钉面,占位值单源常量——manifest 落地后替换) */ +const THEME_SOURCE_HASH_PLACEHOLDER = '0'.repeat(64) +const DEPENDENCY_LOCK_HASH_PLACEHOLDER = '0'.repeat(64) +const ASTRO_VERSION_PLACEHOLDER = '0.0.0' + +export interface CreateBuildJobInput { + siteKey: string + /** 幂等键;缺省 = site + published 集合哈希 + 主题键@版本 自动派生 */ + idempotencyKey?: string + /** 产物根(落 /jobs//{input,reports});缺省 repoRoot/artifacts */ + artifactsRoot?: string + /** 状态机观察钩子(测试/CLI 通道;每态一调,queued 起步) */ + onStatus?: (status: BuildJobStatus, jobId: string) => void +} + +export interface CreateBuildJobResult { + jobId: string + /** job 终态(或复用时既有状态) */ + status: BuildJobStatus + /** true = 命中既有 job(幂等键复用,未重跑、未改行) */ + reused: boolean + siteKey: string + siteId: string + /** 六文件目录 /jobs//input(复用时按本次调用根推导——文件在原 job 落盘根下) */ + inputDir: string + /** 报告路径 /jobs//reports/build.json */ + reportPath: string + /** 三源哈希(新建 = 本次物化;复用 = build_input_snapshots 行值——不可变权威) */ + sourceHashes: { content: string; route: string; asset: string } + /** 以下仅新建路径填充(复用口径忠实化——权威记录在既有报告) */ + counts?: { contents: number; taxonomies: number; routes: number; assets: number } + urlRewrite?: RewriteStats + issues?: string[] + durationMs: number +} + +/** build 报告对象(reports/build.json 固化形状) */ +export interface BuildReport { + jobId: string + generatedAt: string + status: BuildJobStatus + siteKey: string + siteId: string + idempotencyKey: string + theme: { key: string; version: string } + /** 占位声明面(Task 3/5 替换为 manifest 真值——报告读者须知晓当前非真实主题哈希) */ + themePlaceholders: { themeSourceHash: boolean; dependencyLockHash: boolean; astroVersion: boolean } + buildEnv: { + node: string + pnpm: string + sourceCommit: string + locale: string + timezone: string + } + snapshot: { + siteConfigRevisionId: string + manifestPath: string + sourceHashes: { content: string; route: string; asset: string } + counts: { contents: number; taxonomies: number; routes: number; assets: number } + } + files: Array<{ name: string; path: string; sha256: string; byteSize: number }> + urlRewrite: RewriteStats + issues: string[] + durationMs: number +} + +/** pnpm 版本真实探测:npm_config_user_agent(pnpm 生命周期内必设)→ 解析失败 'unknown' */ +function detectPnpmVersion(): string { + const ua = process.env.npm_config_user_agent + if (typeof ua === 'string') { + const m = /pnpm\/([0-9]+(?:\.[0-9]+)*)/.exec(ua) + if (m !== null) return m[1]! + } + return 'unknown' +} + +/** 源提交探测:git rev-parse HEAD(非 git 环境回退 'unknown'——列 NOT NULL 不空) */ +function detectSourceCommit(): string { + try { + return execSync('git rev-parse HEAD', { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return 'unknown' + } +} + +/** 默认幂等键:site + published 集合哈希 + 主题键@版本(定义钉死——报告可复算) */ +export function defaultBuildIdempotencyKey(siteKey: string, publishedRevisionIds: string[]): string { + const setHash = createHash('sha256').update(canonicalJson([...publishedRevisionIds].sort())).digest('hex') + return `build:${siteKey}:pub-${setHash}:${THEME_KEY}@${THEME_VERSION}` +} + +/** temp+rename 原子写(失败清走 tmp 不留半成品——import 同款共用形状) */ +async function writeAtomically(path: string, content: string): Promise { + const tmpPath = `${path}.tmp` + try { + await writeFile(tmpPath, content, 'utf8') + await rename(tmpPath, path) + } catch (err) { + await rm(tmpPath, { force: true }) + throw err + } +} + +/** 既有 job 的复用返回(不动行、不重跑;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */ +async function reuseExisting( + db: BuildDb, + idempotencyKey: string, +): Promise | null> { + const found = await db + .select({ + id: buildJobs.id, + status: buildJobs.status, + contentSnapshotHash: buildInputSnapshots.contentSnapshotHash, + routeSnapshotHash: buildInputSnapshots.routeSnapshotHash, + assetManifestHash: buildInputSnapshots.assetManifestHash, + }) + .from(buildJobs) + .leftJoin(buildInputSnapshots, eq(buildInputSnapshots.buildJobId, buildJobs.id)) + .where(eq(buildJobs.idempotencyKey, idempotencyKey)) + .limit(1) + if (found.length === 0) return null + const row = found[0]! + // 快照行与 job 一对一(build_job_id UNIQUE);终态 job 无快照行 = 上次执行未走到落快照 + // (failed/stale)——三哈希以空串占位复用返回(权威在 job 行 error 面) + return { + jobId: row.id, + status: row.status, + sourceHashes: { + content: row.contentSnapshotHash ?? '', + route: row.routeSnapshotHash ?? '', + asset: row.assetManifestHash ?? '', + }, + } +} + +/** + * 创建(并就地推进到底)一个 build job: + * 解析(site → published 检查 → config 检查)→ 幂等建行(queued)→ snapshotting + * (repeatable read 单事务:物化 + 六文件落盘 + build_input_snapshots 行)→ + * validating(空——Task 6)→ 报告落盘 → succeeded。 + * dist 产物与 build_artifacts 登记是 Task 5 职责(本任务 dist 尚空)。 + */ +export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): Promise { + const startedAt = performance.now() + const artifactsRoot = resolve(input.artifactsRoot ?? join(REPO_ROOT, 'artifacts')) + + // 阶段 0:站点解析(不自动建站——建站是 register 的职责) + const siteRows = await db + .select({ id: sites.id, key: sites.key, defaultLocale: sites.defaultLocale }) + .from(sites) + .where(eq(sites.key, input.siteKey)) + .limit(1) + if (siteRows.length === 0) { + throw new BuildError('SITE_NOT_FOUND', `站点不存在:${input.siteKey}(先运行 sf source:register)`) + } + const site = siteRows[0]! + + // 阶段 1:publish 状态检查(建行前——零发布集无物化意义,不留悬挂 job 行)。 + // 幂等键输入 = published 修订 id 集合(ADR-010:发布后导入产新 current 不入键)。 + const publishedRows = await db + .select({ publishedRevisionId: entryLocales.publishedRevisionId }) + .from(entryLocales) + .where(eq(entryLocales.siteId, site.id)) + const publishedIds = publishedRows + .map((r) => r.publishedRevisionId) + .filter((id): id is string => id !== null) + .sort() + if (publishedIds.length === 0) { + throw new BuildError( + 'NOT_PUBLISHED', + `站点 ${input.siteKey} 没有任何已发布条目(entry_locales.published_revision_id 全空)——先运行 sf content:publish`, + ) + } + + // 阶段 2:config 修订检查(site.json 输入;Task 1 保证首发布即有 v1——防御缺席) + const configRows = await db + .select({ id: siteConfigRevisions.id }) + .from(siteConfigRevisions) + .where(eq(siteConfigRevisions.siteId, site.id)) + .orderBy(desc(siteConfigRevisions.version)) + .limit(1) + if (configRows.length === 0) { + throw new BuildError( + 'NO_SITE_CONFIG', + `站点 ${input.siteKey} 无 site_config_revisions(publish 首版不变量破裂——site.json 无输入)`, + ) + } + + // 阶段 3:幂等建 job(queued)——命中既有 key 直接复用返回(不重跑) + 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 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, + } + } + } + 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 纪律 + try { + // 阶段 4:snapshotting(attempt=1、本地 worker 就地推进) + await db + .update(buildJobs) + .set({ status: 'snapshotting', startedAt: new Date(), attempt: 1, workerId: 'local' }) + .where(eq(buildJobs.id, jobId)) + input.onStatus?.('snapshotting', jobId) + + // 阶段 5:repeatable read 单事务——一致性读取(published 集/路由/资产同一时点)+ + // 六文件落盘 + build_input_snapshots 行(读取面与登记面原子:行在即文件内容可复算) + const buildTimestamp = new Date() + const mat = await db.transaction( + async (tx) => { + const m = await materializeRenderSnapshot(tx, { siteId: site.id, generatedAt: buildTimestamp.toISOString() }) + await mkdir(inputDir, { recursive: true }) + for (const name of Object.keys(m.files) as Array) { + await writeAtomically(join(inputDir, name), m.files[name]) + } + await tx.insert(buildInputSnapshots).values({ + buildJobId: jobId!, + siteId: site.id, + contentSnapshotHash: m.sourceHashes.content, + routeSnapshotHash: m.sourceHashes.route, + assetManifestHash: m.sourceHashes.asset, + siteConfigRevisionId: m.siteConfigRevisionId, + themeKey: THEME_KEY, + themeVersion: THEME_VERSION, + themeSourceHash: THEME_SOURCE_HASH_PLACEHOLDER, + dependencyLockHash: DEPENDENCY_LOCK_HASH_PLACEHOLDER, + sourceCommit: detectSourceCommit(), + nodeVersion: process.versions.node, + pnpmVersion: detectPnpmVersion(), + astroVersion: ASTRO_VERSION_PLACEHOLDER, + locale: site.defaultLocale, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + buildTimestamp, + manifestPath: join(inputDir, 'render-manifest.json'), + }) + return m + }, + { isolationLevel: 'repeatable read' }, + ) + + // 阶段 6:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整) + await db.update(buildJobs).set({ status: 'validating' }).where(eq(buildJobs.id, jobId)) + input.onStatus?.('validating', jobId) + + // 阶段 7:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在 + const report: BuildReport = { + jobId, + generatedAt: buildTimestamp.toISOString(), + status: 'succeeded', + siteKey: site.key, + siteId: site.id, + idempotencyKey, + theme: { key: THEME_KEY, version: THEME_VERSION }, + themePlaceholders: { + themeSourceHash: true, + dependencyLockHash: true, + astroVersion: true, + }, + buildEnv: { + node: process.versions.node, + pnpm: detectPnpmVersion(), + sourceCommit: detectSourceCommit(), + locale: site.defaultLocale, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + }, + snapshot: { + siteConfigRevisionId: mat.siteConfigRevisionId, + manifestPath: join(inputDir, 'render-manifest.json'), + sourceHashes: mat.sourceHashes, + counts: mat.counts, + }, + files: Object.entries(mat.files).map(([name, content]) => ({ + name, + path: join(inputDir, name), + sha256: createHash('sha256').update(content, 'utf8').digest('hex'), + byteSize: Buffer.byteLength(content, 'utf8'), + })), + urlRewrite: mat.urlRewrite, + issues: mat.issues, + durationMs: performance.now() - startedAt, + } + try { + await mkdir(reportDir, { recursive: true }) + await writeAtomically(reportPath, `${JSON.stringify(report, null, 2)}\n`) + } catch (err) { + throw new BuildError( + 'REPORT_WRITE_FAILED', + `构建报告落盘失败(${reportPath}):${err instanceof Error ? err.message : String(err)}`, + ) + } + + // 阶段 8:succeeded 终态 + await db.update(buildJobs).set({ status: 'succeeded', finishedAt: new Date() }).where(eq(buildJobs.id, jobId)) + input.onStatus?.('succeeded', jobId) + + return { + jobId, + status: 'succeeded', + reused: false, + siteKey: site.key, + siteId: site.id, + inputDir, + reportPath, + sourceHashes: mat.sourceHashes, + counts: mat.counts, + urlRewrite: mat.urlRewrite, + issues: mat.issues, + durationMs: performance.now() - startedAt, + } + } catch (err) { + const code = err instanceof BuildError ? err.code : 'SNAPSHOT_FAILED' + const summary = err instanceof Error ? `${err.name}: ${err.message}` : String(err) + await db + .update(buildJobs) + .set({ status: 'failed', finishedAt: new Date(), errorCode: code, errorSummary: `[build] ${summary}` }) + .where(eq(buildJobs.id, jobId)) + throw err + } +} diff --git a/apps/api/src/modules/build/render-snapshot.ts b/apps/api/src/modules/build/render-snapshot.ts new file mode 100644 index 0000000..fd85b82 --- /dev/null +++ b/apps/api/src/modules/build/render-snapshot.ts @@ -0,0 +1,479 @@ +// Render Snapshot 纯物化(Plan 4 Task 2,ADR-011、V2 spec §9.2、快线 §8.4): +// 从 published 修订 + route_registry + media ready 集合物化冻结六文件 +// (RENDER_SNAPSHOT_FILES 契约清单),**db 只读、零写入**(磁盘仅做副本存在性探测)。 +// 构建编排(job 状态机/落盘/快照行)在 build.service.ts——本文件只负责"读出什么"。 +// +// 发布语义(ADR-010):只读 entry_locales.published_revision_id 指向的修订——current +// 分叉(发布后再导入产新修订)不影响本快照;发布后的人工/导入变化须重新 publish + +// 新 build 才进产物。published 指针悬空(目标修订行缺席,指针列无 FK)→ issue 如实 +// 报告(§7.6 禁静默),该条目不进 content.json。 +// +// bodyHtml URL 重写(计划 Task 2 钉死匹配键):正则提取 src/href → 剥 host 取 path → +// 与 media ready 集合的 public_path / source_url 路径段**双源**比对(覆盖绝对/根相对/ +// 协议相对变体与查询串)→ 命中改写为根相对 public_path;未命中保留原样 + unresolved +// 计数(render-manifest.urlRewrite——内链 miss 是常态统计不是错误;外域热链同理)。 +// data:/纯片段/相对形态无路径可比对,原样越过不计(与 media-writer 的 data: 口径一致)。 +// +// canonical JSON 确定性:六文件全部以 canonicalJson(键递归字典序、无空白)+ 换行落盘 +// ——文件字节即哈希输入(content/route/asset 三哈希 = 各自 canonicalJson 的 sha256), +// 同输入(含 generatedAt 时点参数)两次物化字节一致。集合排序在 JS 侧钉死(UTF-16 +// 序,不依赖 PG collation):内容按 (typeKey, slug, locale, contentId)、分类按 (key, +// objectType)、term 按 (sourceId, slug, id)、路由按 (routePath, queryKey nulls-first, +// host)、资产按 publicPath。 +// +// 媒体实体路径解析(assets.json 的 copyPath):register 快照 metadata.storageRoot + +// storage_path(content-addressed 副本)为首选,仓库存储根同路径次之,storage/incoming +// 按清单文件名兜底(真实数据扁平 GUID 命名,basename 即唯一键);全部缺席 → issue +// 如实报告(复制防线在 Task 5 构建管线,此处不静默也不阻断)。 +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 type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { + ADAPTER_VERSION, + MAPPING_VERSION, + PARSER_VERSION, + SANITIZER_VERSION, +} from '@sf/adapter-wp' +import { RENDER_SNAPSHOT_VERSION, type RenderSnapshotFile } from '@sf/contracts' +import { canonicalJson } from '../../common/canonical.js' +import { + contentEntries, + contentRevisions, + contentTerms, + entryLocales, + mediaAssets, + mediaUsages, + routeRegistry, + siteConfigRevisions, + sourceSnapshots, + taxonomies, + terms, +} from '../../db/schema.js' +import type * as schema from '../../db/schema.js' + +/** 本文件位于 /apps/api/src/modules/build/ → 上五级是仓库根 */ +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..') + +/** 读取面:db 池与事务句柄都满足(build.service 以 repeatable read 事务传入) */ +export type SnapshotDb = NodePgDatabase +export type SnapshotReader = Pick + +// ---- URL 重写(纯函数,测试直测) ---- + +/** media ready 集合的最小匹配面(public_path 与 source_url 双源建键) */ +export interface MediaPathSource { + publicPath: string + sourceUrl: string +} + +/** + * URL → 可比对路径:剥 host(绝对/协议相对)、剥查询/锚、保证首斜杠; + * data:/纯片段/相对形态返回 null(无路径可比对,原样越过)。 + */ +function comparablePathOf(raw: string): string | null { + if (raw === '' || raw.startsWith('data:')) return null + let rest: string + if (raw.startsWith('//')) { + const pathStart = raw.indexOf('/', 2) + if (pathStart === -1) return null // //host 无路径证据——不比对('/' 会误伤首页) + rest = raw.slice(pathStart) + } else { + const abs = /^https?:\/\/[^/]+(.*)$/i.exec(raw) + if (abs !== null) { + rest = abs[1] === '' ? '/' : abs[1]! + } else if (raw.startsWith('/')) { + rest = raw + } else { + return null + } + } + const cut = rest.search(/[?#]/) + const path = cut === -1 ? rest : rest.slice(0, cut) + return path === '' ? '/' : path +} + +/** + * media ready 路径索引:path(public_path 本形 + source_url 剥 host 的 path 段)→ + * 该资产的 public_path(命中改写目标)。同 path 双源冲突时先登记者胜(register 的 + * public_path 唯一键保证本形不冲突;跨源同 path 是数据面事实,任一命中即本地化)。 + */ +export function buildMediaPathIndex(assets: MediaPathSource[]): Map { + const index = new Map() + for (const a of assets) { + index.set(a.publicPath, a.publicPath) + const src = comparablePathOf(a.sourceUrl) + if (src !== null && !index.has(src)) index.set(src, a.publicPath) + } + return index +} + +/** 重写统计(入 render-manifest.urlRewrite 与 build 报告 issues 面) */ +export interface RewriteStats { + /** 命中 ready 集合的引用数(含已是根相对形态的幂等命中) */ + rewritten: number + /** 有路径可比对但未命中 ready 集合的引用数(保留原样) */ + unresolved: number +} + +/** + * bodyHtml 的 src/href URL 重写(正则提取——清洗信任链上游已保证属性形态规整; + * 门禁期的 HTML 解析复用清洗管线是 Task 6 职责,此处不引入解析器依赖)。 + * 只替换属性值本身,引号/空白原样保留;命中值与原值相等时计数但零改写。 + */ +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 path = comparablePathOf(value) + if (path === null) return full + const hit = index.get(path) + if (hit === undefined) { + stats.unresolved++ + return full + } + stats.rewritten++ + if (hit === value) return full + // match 尾部 = value + 收引号——只换值,保留原引号与属性的原始空白 + return `${full.slice(0, full.length - value.length - 1)}${hit}${quote}` + }) + return { html: out, stats } +} + +// ---- 物化 ---- + +/** 六文件的 canonical JSON 字符串(含尾换行——落盘字节即哈希输入 + '\n') */ +export type RenderSnapshotFileContents = Record + +export interface MaterializeInput { + siteId: string + /** 快照生成时点(ISO 字符串)——render-manifest.generatedAt;同输入 + 同时点 → 字节一致 */ + generatedAt: string +} + +export interface MaterializeResult { + files: RenderSnapshotFileContents + sourceHashes: { + content: string + route: string + asset: string + } + counts: { + contents: number + taxonomies: number + routes: number + assets: number + } + urlRewrite: RewriteStats + /** 物化期如实报告的问题(published 悬空/媒体副本缺席/不可服务路由形态)——进 build 报告 */ + issues: string[] + /** site.json 来源的 config 修订(build_input_snapshots.site_config_revision_id 落值) */ + siteConfigRevisionId: string +} + +/** UTF-16 字典序比较器(与 canonicalJson 键排序同语义——钉死不依赖 PG collation) */ +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} +function cmpNull(a: string | null, b: string | null): number { + if (a === null && b === null) return 0 + if (a === null) return -1 + if (b === null) return 1 + return cmp(a, b) +} + +/** sha256 of canonicalJson(三哈希的单一算法来源) */ +function hashOf(payload: unknown): string { + return createHash('sha256').update(canonicalJson(payload)).digest('hex') +} + +/** 仓库存储根的 incoming 媒体兜底目录(真实数据:storage/incoming/<目录>/<扁平 GUID 文件>) */ +const REPO_STORAGE = join(REPO_ROOT, 'storage') + +/** + * 物化冻结 Render Snapshot 六文件(纯读 + 副本存在性探测)。 + * 前置不变量:站点已有 published 指针与 config 修订(build.service 建行前检查; + * 直接调用方破坏不变量时以明错拒绝,不产残缺快照)。 + */ +export async function materializeRenderSnapshot( + db: SnapshotReader, + input: MaterializeInput, +): Promise { + const issues: string[] = [] + const urlRewrite: RewriteStats = { rewritten: 0, unresolved: 0 } + + // site.json:config 修订最新版(Task 1 首发布生成 v1——data={name,origin,locale,brand}) + const cfgRows = await db + .select({ id: siteConfigRevisions.id, data: siteConfigRevisions.data }) + .from(siteConfigRevisions) + .where(eq(siteConfigRevisions.siteId, input.siteId)) + .orderBy(desc(siteConfigRevisions.version)) + .limit(1) + const config = cfgRows[0] + if (config === undefined) { + throw new Error(`站点 ${input.siteId} 无 site_config_revisions(publish 首版不变量破裂)——拒绝物化`) + } + + // content.json:published 修订(leftJoin 显式暴露悬空指针——§7.6 禁静默) + const localeRows = await db + .select({ + contentId: contentEntries.id, + typeKey: contentEntries.typeKey, + locale: entryLocales.locale, + localeSlug: entryLocales.slug, + revisionId: contentRevisions.id, + checksum: contentRevisions.checksum, + data: contentRevisions.data, + }) + .from(entryLocales) + .innerJoin(contentEntries, eq(entryLocales.contentEntryId, contentEntries.id)) + .leftJoin(contentRevisions, eq(entryLocales.publishedRevisionId, contentRevisions.id)) + .where(and(eq(entryLocales.siteId, input.siteId), isNotNull(entryLocales.publishedRevisionId))) + const published = localeRows.filter((r) => r.revisionId !== null) + for (const row of localeRows) { + if (row.revisionId === null) { + issues.push( + `published 指针悬空(entry=${row.contentId}/${row.locale}):published_revision_id 指向的修订行缺席(指针列无 FK)——该条目不进 content.json`, + ) + } + } + + // media ready 集合:资产面(assets.json)与 URL 重写索引共用的单一查询 + const assetRows = await db + .select({ + publicPath: mediaAssets.publicPath, + sourceUrl: mediaAssets.sourceUrl, + storagePath: mediaAssets.storagePath, + sha256: mediaAssets.sha256, + detectedMimeType: mediaAssets.detectedMimeType, + byteSize: mediaAssets.byteSize, + width: mediaAssets.width, + height: mediaAssets.height, + altText: mediaAssets.altText, + }) + .from(mediaAssets) + .where(and(eq(mediaAssets.siteId, input.siteId), eq(mediaAssets.status, 'ready'))) + assetRows.sort((a, b) => cmp(a.publicPath, b.publicPath)) + const mediaIndex = buildMediaPathIndex(assetRows) + + // published 修订的 media_usages 引用面(fieldPath → public_path;content.json 的 media 块) + const publishedIds = published.map((r) => r.revisionId!) + const usageMap = new Map>() + if (publishedIds.length > 0) { + const usages = await db + .select({ + contentRevisionId: mediaUsages.contentRevisionId, + fieldPath: mediaUsages.fieldPath, + publicPath: mediaAssets.publicPath, + }) + .from(mediaUsages) + .innerJoin(mediaAssets, eq(mediaUsages.mediaAssetId, mediaAssets.id)) + .where(and(eq(mediaUsages.siteId, input.siteId), inArray(mediaUsages.contentRevisionId, publishedIds))) + 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 contentPayload = published.map((row) => { + const data = (row.data ?? {}) as Record + let entryData = data + if (typeof data.bodyHtml === 'string') { + const r = rewriteBodyHtml(data.bodyHtml, mediaIndex) + urlRewrite.rewritten += r.stats.rewritten + urlRewrite.unresolved += r.stats.unresolved + entryData = { ...data, bodyHtml: r.html } + } + // slug 以修订 data.slug(发布时点冻结值)为准,缺席回退 entry_locales.slug(current 跟随面) + const slug = typeof data.slug === 'string' && data.slug !== '' ? data.slug : row.localeSlug + const media = usageMap.get(row.revisionId!) + const entry: Record = { + contentId: row.contentId, + typeKey: row.typeKey, + locale: row.locale, + slug, + data: entryData, + revisionId: row.revisionId, + checksum: row.checksum, + } + if (media !== undefined && media.size > 0) { + entry.media = Object.fromEntries([...media.entries()].sort(([a], [b]) => cmp(a, b))) + } + return entry + }) + contentPayload.sort((a, b) => + cmp(a.typeKey as string, b.typeKey as string) + || cmp(a.slug as string, b.slug as string) + || cmp(a.locale as string, b.locale as string) + || cmp(a.contentId as string, b.contentId as string), + ) + + // taxonomies.json:taxonomies × terms 全量 + content_terms 关联(auto: 自建行 source_id 原样) + const taxonomyRows = await db + .select({ + id: taxonomies.id, + key: taxonomies.key, + objectType: taxonomies.objectType, + publicBasePath: taxonomies.publicBasePath, + }) + .from(taxonomies) + .where(eq(taxonomies.siteId, input.siteId)) + const termRows = await db + .select({ + id: terms.id, + taxonomyId: terms.taxonomyId, + sourceId: terms.sourceId, + slug: terms.slug, + name: terms.name, + parentTermId: terms.parentTermId, + data: terms.data, + }) + .from(terms) + .where(eq(terms.siteId, input.siteId)) + const relationRows = await db + .select({ termId: contentTerms.termId, contentEntryId: contentTerms.contentEntryId }) + .from(contentTerms) + .where(eq(contentTerms.siteId, input.siteId)) + const entriesByTerm = new Map() + for (const rel of relationRows) { + const list = entriesByTerm.get(rel.termId) ?? [] + list.push(rel.contentEntryId) + entriesByTerm.set(rel.termId, list) + } + const termsByTaxonomy = new Map() + for (const t of termRows) { + const list = termsByTaxonomy.get(t.taxonomyId) ?? [] + list.push(t) + termsByTaxonomy.set(t.taxonomyId, list) + } + const taxonomiesPayload = taxonomyRows.map((tax) => { + const group = (termsByTaxonomy.get(tax.id) ?? []).map((t) => ({ + id: t.id, + sourceId: t.sourceId, + slug: t.slug, + name: t.name, + parentTermId: t.parentTermId, + data: (t.data ?? {}) as Record, + contentEntryIds: (entriesByTerm.get(t.id) ?? []).sort(cmp), + })) + group.sort((a, b) => cmp(a.sourceId, b.sourceId) || cmp(a.slug, b.slug) || cmp(a.id, b.id)) + return { key: tax.key, objectType: tax.objectType, publicBasePath: tax.publicBasePath, terms: group } + }) + taxonomiesPayload.sort((a, b) => cmp(a.key, b.key) || cmp(a.objectType, b.objectType)) + + // routes.json:可服务路由形态(html/asset/redirect/gone);dynamic/unresolved 不进产物 + const routeRows = await db + .select({ + host: routeRegistry.host, + routePath: routeRegistry.routePath, + routeKind: routeRegistry.routeKind, + queryPolicy: routeRegistry.queryPolicy, + queryKey: routeRegistry.queryKey, + targetStatus: routeRegistry.targetStatus, + targetContentId: routeRegistry.targetContentId, + targetFile: routeRegistry.targetFile, + redirectStatus: routeRegistry.redirectStatus, + redirectLocation: routeRegistry.redirectLocation, + indexable: routeRegistry.indexable, + priority: routeRegistry.priority, + verificationStatus: routeRegistry.verificationStatus, + }) + .from(routeRegistry) + .where(eq(routeRegistry.siteId, input.siteId)) + const SERVICABLE = new Set(['html', 'asset', 'redirect', 'gone']) + const unservicable = routeRows.filter((r) => !SERVICABLE.has(r.routeKind)) + if (unservicable.length > 0) { + issues.push( + `路由表含 ${unservicable.length} 条不可服务形态(dynamic/unresolved)——不进 routes.json(处置是 SP3+ 查询路由域)`, + ) + } + const routesPayload = routeRows.filter((r) => SERVICABLE.has(r.routeKind)) + routesPayload.sort( + (a, b) => cmp(a.routePath, b.routePath) || cmpNull(a.queryKey, b.queryKey) || cmp(a.host, b.host), + ) + + // assets.json:ready 资产 + 实体副本路径解析(content-addressed 首选 → 仓库存储根 → incoming 兜底) + const bundleRows = await db + .select({ metadata: sourceSnapshots.metadata }) + .from(sourceSnapshots) + .where(and(eq(sourceSnapshots.siteId, input.siteId), eq(sourceSnapshots.kind, 'media_bundle'))) + .orderBy(desc(sourceSnapshots.capturedAt), desc(sourceSnapshots.createdAt)) + .limit(1) + const bundleRootRaw = bundleRows[0]?.metadata as { storageRoot?: unknown } | undefined + const bundleRoot = typeof bundleRootRaw?.storageRoot === 'string' ? bundleRootRaw.storageRoot : null + + const assetsPayload = assetRows.map((a) => { + const candidates: string[] = [] + if (bundleRoot !== null && bundleRoot !== '') candidates.push(join(bundleRoot, a.storagePath)) + const repoCopy = join(REPO_STORAGE, a.storagePath) + candidates.push(repoCopy) + // incoming 兜底:清单文件名(真实数据扁平 GUID 命名,basename 唯一) + candidates.push(join(REPO_STORAGE, 'incoming', basename(a.storagePath))) + let copyPath = candidates[0] ?? repoCopy + for (const c of candidates) { + if (existsSync(c)) { + copyPath = c + break + } + } + if (!existsSync(copyPath)) { + issues.push( + `媒体副本缺席(public_path=${a.publicPath}):候选 ${candidates.join(' ; ')} 均不存在——assets.json 记录首选路径,复制阶段(Task 5 管线)将失败`, + ) + } + return { + publicPath: a.publicPath, + storagePath: a.storagePath, + copyPath, + sha256: a.sha256, + detectedMimeType: a.detectedMimeType, + byteSize: a.byteSize, + width: a.width, + height: a.height, + altText: a.altText, + } + }) + + // 三哈希(各文件 canonicalJson 的 sha256——字节可复算)+ render-manifest 汇聚 + const sourceHashes = { + content: hashOf(contentPayload), + route: hashOf(routesPayload), + asset: hashOf(assetsPayload), + } + const counts = { + contents: contentPayload.length, + taxonomies: taxonomiesPayload.length, + routes: routesPayload.length, + assets: assetsPayload.length, + } + const manifestPayload = { + renderSnapshotVersion: RENDER_SNAPSHOT_VERSION, + versions: { + adapter: ADAPTER_VERSION, + mapping: MAPPING_VERSION, + sanitizer: SANITIZER_VERSION, + parser: PARSER_VERSION, + }, + counts, + sourceHashes, + urlRewrite, + generatedAt: input.generatedAt, + } + + const file = (payload: unknown): string => `${canonicalJson(payload)}\n` + const files: RenderSnapshotFileContents = { + 'site.json': file(config.data), + 'content.json': file(contentPayload), + 'taxonomies.json': file(taxonomiesPayload), + 'routes.json': file(routesPayload), + 'assets.json': file(assetsPayload), + 'render-manifest.json': file(manifestPayload), + } + return { files, sourceHashes, counts, urlRewrite, issues, siteConfigRevisionId: config.id } +} diff --git a/scripts/sf.ts b/scripts/sf.ts index e9274b9..8ee9b85 100644 --- a/scripts/sf.ts +++ b/scripts/sf.ts @@ -1,13 +1,14 @@ #!/usr/bin/env node -// sf CLI(Plan 2 Task 7 + Plan 3 Task 1 + Plan 4 Task 1):commander 四命令—— +// sf CLI(Plan 2 Task 7 + Plan 3 Task 1 + Plan 4 Task 1-2):commander 五命令—— // - wxr:probe:只读诊断(任意可读路径;Task 8 直读 cms 用此通道),出 JSON/HTML 报告不入库; // - source:register:调 registerSource 登记 content-addressed 快照(路径白名单校验在服务内); // - import:调 createImportJob 建 import_jobs 并推进生命周期(Task 6:默认编排管道——四家写入器汇流); -// - content:publish:调 publishSiteContent 显式发布(dry-run 清单+confirm token → --confirm 执行)。 +// - content:publish:调 publishSiteContent 显式发布(dry-run 清单+confirm token → --confirm 执行); +// - build:create:调 createBuildJob 冻结 Render Snapshot(六文件物化 + build_input_snapshots;dist 是 Task 5)。 // 执行机制(Plan 2 评审钉死):根 devDep tsx 直吃 TS 源码(含 apps/api 的 Nest 源码与 // packages 的装饰器/TS 源文件),@sf/* 经根/包 tsconfig paths 直连 src——无需预构建 dist。 -// 注意:CLI 只 import register.service.ts / import.service.ts / publish.service.ts -// (零装饰器纯函数文件),不拉起 Nest 模块——反射元数据依赖由此避免;module 文件留给 standalone context。 +// 注意:CLI 只 import register.service.ts / import.service.ts / publish.service.ts / +// build.service.ts(零装饰器纯函数文件),不拉起 Nest 模块——反射元数据依赖由此避免;module 文件留给 standalone context。 import { createReadStream } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' @@ -15,6 +16,7 @@ import { fileURLToPath } from 'node:url' import { Command } from 'commander' import { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '@sf/adapter-wp' import { getDb } from '../apps/api/src/db/client.js' +import { BuildError, createBuildJob } from '../apps/api/src/modules/build/build.service.js' import { ImportError, createImportJob, @@ -54,7 +56,7 @@ function printProbeSummary(report: WxrAdapterReport): void { const program = new Command() program .name('sf') - .description('SiteForge CLI(阶段 3:来源输入登记、WXR 探测与导入 job 生命周期)') + .description('SiteForge CLI(阶段 4:来源登记、导入、显式发布与 Render Snapshot 冻结构建)') .version('0.1.0') program @@ -297,8 +299,51 @@ program } }) +program + .command('build:create') + .description('冻结 Render Snapshot:物化六文件(site/content/taxonomies/routes/assets/render-manifest)+ build_input_snapshots 行;dist 产物是 Task 5') + .requiredOption('--site ', '站点 key') + .option('--key ', '幂等键(缺省 = site+published 集合哈希+主题版本 自动派生;重复提交同 key 复用既有 job 不重跑)') + .option('--artifacts ', '产物根目录(缺省 repoRoot/artifacts)') + .action(async (opts: { site: string; key?: string; artifacts?: string }) => { + // getDb 走 DATABASE_URL_TEST ?? DATABASE_URL 回退链——临时库冒烟只需 env 覆盖 + const db = getDb() + // exactOptionalPropertyTypes:缺席键不传 undefined 值(与 source:register 同款) + const input: { siteKey: string; idempotencyKey?: string; artifactsRoot?: string } = { siteKey: opts.site } + if (opts.key !== undefined) input.idempotencyKey = opts.key + if (opts.artifacts !== undefined) input.artifactsRoot = opts.artifacts + try { + const result = await createBuildJob(db, input) + console.log( + `[build:create] job=${result.jobId} status=${result.status}${result.reused ? '(复用既有 job,未重跑)' : ''}`, + ) + console.log(` site=${result.siteKey} input: ${result.inputDir}`) + if (!result.reused) { + const c = result.counts! + console.log( + ` counts: contents=${c.contents} taxonomies=${c.taxonomies} routes=${c.routes} assets=${c.assets}`, + ) + console.log( + ` urlRewrite: rewritten=${result.urlRewrite!.rewritten} unresolved=${result.urlRewrite!.unresolved}`, + ) + if (result.issues!.length > 0) { + console.log(` issues (${result.issues!.length}):`) + for (const issue of result.issues!) console.log(` - ${issue}`) + } + } + console.log( + ` hashes: content=${result.sourceHashes.content.slice(0, 12)}… route=${result.sourceHashes.route.slice(0, 12)}… asset=${result.sourceHashes.asset.slice(0, 12)}…`, + ) + console.log(` report: ${result.reportPath}`) + console.log(` total ${result.durationMs.toFixed(1)}ms`) + if (result.status === 'failed') process.exitCode = 1 + } finally { + await (db as { $client: { end: () => Promise } }).$client.end() + } + }) + // parseAsync:action 内抛错走此 catch(commander 默认会 process.exit(1) 并打印堆栈, -// 这里收敛为单行错误信息 + exitCode——SourceRegisterError/ImportError/PublishError 打 code,其余原样呈现) +// 这里收敛为单行错误信息 + exitCode——SourceRegisterError/ImportError/PublishError/BuildError 打 code,其余原样呈现) program.parseAsync(process.argv).catch((err: unknown) => { if (err instanceof SourceRegisterError) { console.error(`[sf] ${err.code}: ${err.message}`) @@ -309,6 +354,8 @@ program.parseAsync(process.argv).catch((err: unknown) => { console.error(`[sf] ${err.code}: ${err.message}`) } else if (err instanceof PublishError) { console.error(`[sf] ${err.code}: ${err.message}`) + } else if (err instanceof BuildError) { + console.error(`[sf] ${err.code}: ${err.message}`) } else { console.error(`[sf] ${err instanceof Error ? err.message : String(err)}`) } diff --git a/tests/integration/build-snapshot.test.ts b/tests/integration/build-snapshot.test.ts new file mode 100644 index 0000000..cc36823 --- /dev/null +++ b/tests/integration/build-snapshot.test.ts @@ -0,0 +1,583 @@ +// Plan 4 Task 2 失败测试(先于实现):Render Snapshot 物化 + build job(ADR-011、V2 spec §9)。 +// 覆盖契约(对照计划 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 不重跑); +// 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/ +// redirect_status/redirect_location/verification_status/indexable/priority 保留)/ +// assets.json(ready 资产实体路径解析:register 快照 metadata.storageRoot+storage_path)/ +// render-manifest.json(versions 四常量 + counts + sourceHashes 三哈希 + urlRewrite 统计); +// 3. bodyHtml URL 重写:mini post 101 正文绝对 URL 图片命中 media ready 集合 → 根相对 +// public_path;未命中 URL 保留原样 + rewritten/unresolved 计数入 render-manifest; +// 4. canonical JSON 确定性:同输入两次物化 → 六文件字节一致(键排序、无空白); +// 5. NOT_PUBLISHED:未 publish 站点 → 明确报错且零 build_jobs 行; +// 6. ADR-010 双修订:发布 A → 再导入改内容 current=B → 新 key 再 build → content.json +// 仍为 A(published 冻结语义,spec §15 阶段 4 验收明文)。 +// 运行于 globalSetup 临时库(ADR-015);storage/artifacts 根 os.tmpdir,finally 清理。 +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + ADAPTER_VERSION, + MAPPING_VERSION, + PARSER_VERSION, + SANITIZER_VERSION, +} from '@sf/adapter-wp' +import { canonicalJson } from '../../apps/api/src/common/canonical.js' +import { createImportJob } from '../../apps/api/src/modules/import/import.service.js' +import { publishSiteContent } from '../../apps/api/src/modules/publish/publish.service.js' +import { registerSource } from '../../apps/api/src/modules/source/register.service.js' +import { + BuildError, + createBuildJob, +} from '../../apps/api/src/modules/build/build.service.js' +import { + buildMediaPathIndex, + materializeRenderSnapshot, + rewriteBodyHtml, +} from '../../apps/api/src/modules/build/render-snapshot.js' +import { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js' + +const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url)) +const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml') + +const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +/** 伪 PNG 体(签名 + 唯一种子载荷——魔数过检、内容唯一避免跨测试撞哈希) */ +function pngBytes(seed: string): Buffer { + return Buffer.concat([PNG_SIG, Buffer.from(`png-body:${seed}`, 'latin1')]) +} +function sha256of(buf: Buffer | string): string { + return createHash('sha256').update(buf).digest('hex') +} +/** db.execute 行对象规整(原型差异会让 toStrictEqual 报"无视觉差异"失败——import-media 同款) */ +function plain(rows: any[]): any[] { + return rows.map((r) => ({ ...r })) +} +/** 单值计数便捷 */ +async function countOf(db: Db, sql: string): Promise { + const rows = (await db.execute(sql)) as any + return Number(rows[0].n) +} + +/** 本文件写入的行清场:构建域(不可变快照先行)+ 发布/导入/来源全链反序 */ +async function cleanupSiteRows(db: Db, siteId: string): Promise { + await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`) + await db.execute(`delete from build_jobs where site_id = '${siteId}'`) + await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `site_id = '${siteId}'`) + await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`) + await db.execute(`delete from route_registry where site_id = '${siteId}'`) + await db.execute(`delete from media_usages where site_id = '${siteId}'`) + await db.execute(`delete from media_assets where site_id = '${siteId}'`) + await db.execute(`delete from content_terms where site_id = '${siteId}'`) + await db.execute(`delete from terms where site_id = '${siteId}'`) + await db.execute(`delete from taxonomies where site_id = '${siteId}'`) + await db.execute(`delete from source_records where site_id = '${siteId}'`) + await db.execute(`delete from entry_locales where site_id = '${siteId}'`) + await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`) + await db.execute(`delete from content_entries where site_id = '${siteId}'`) + await db.execute(`delete from import_jobs where site_id = '${siteId}'`) + await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`) + await db.execute(`delete from source_connections where site_id = '${siteId}'`) + await db.execute(`delete from sites where id = '${siteId}'`) +} + +interface Work { + work: string + storageRoot: string + allowedRoots: string[] + pngA: Buffer + pngB: Buffer +} + +async function makeWork(prefix: string): Promise { + const work = await mkdtemp(join(tmpdir(), `${prefix}-`)) + // 媒体目录:fixture-a/b 按旧 uploads 层级放(清单精确命中 → media_assets ready 两行) + const mediaDir = join(work, 'media') + await mkdir(join(mediaDir, '2026', '10'), { recursive: true }) + const pngA = pngBytes(`build-a-${rand()}`) + const pngB = pngBytes(`build-b-${rand()}`) + await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), pngA) + await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), pngB) + return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work], pngA, pngB } +} + +/** mini 全链(register wxr+media → import → publish):3 条 published + 2 ready 媒体 */ +async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> { + const key = `sfbuild-${rand()}` + const reg = await registerSource(db, { + siteKey: key, + wxrPath: WXR_FIXTURE, + mediaDir: join(w.work, 'media'), + allowedRoots: w.allowedRoots, + storageRoot: w.storageRoot, + reportRoot: join(w.work, 'artifacts', 'register'), + }) + const result = await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') }) + // gallery 203/204 悬空 → issues 非空是预期(§7.6 禁静默) + expect(result.status).toBe('succeeded_with_issues') + const dry = await publishSiteContent(db, { siteKey: key, dryRun: true }) + const run = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! }) + expect(run.publishedCount).toBe(3) + return { key, siteId: reg.siteId } +} + +/** 读取六输入文件为解析对象 */ +async function readInputFiles(inputDir: string): Promise> { + const names = ['site', 'content', 'taxonomies', 'routes', 'assets', 'render-manifest'] + const out: Record = {} + for (const n of names) { + out[n] = JSON.parse(await readFile(join(inputDir, `${n}.json`), 'utf8')) + } + return out +} + +describe('build job + Render Snapshot 物化', () => { + it('状态机 queued→snapshotting→validating(空)→succeeded + build_input_snapshots 行(三哈希/主题/真实版本列)+ 幂等复用', async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-build-sm') + try { + const { key, siteId } = await seedPublishedSite(db, w) + try { + const statuses: string[] = [] + const result = await createBuildJob(db, { + siteKey: key, + artifactsRoot: join(w.work, 'artifacts'), + onStatus: (s) => statuses.push(s), + }) + expect(result.reused).toBe(false) + expect(result.status).toBe('succeeded') + // 状态机序列(validating 空:Task 6 填真实门禁) + expect(statuses).toStrictEqual(['queued', 'snapshotting', 'validating', 'succeeded']) + + // build_jobs 行:终态 + attempt/时间戳/error 空(epoch 投影——时间戳非空身份断言; + // raw execute 的 timestamptz 回串不做 Date 形状断言) + const job = (await db.execute(`select status, attempt, + extract(epoch from started_at)::float8 as started_ts, + extract(epoch from finished_at)::float8 as finished_ts, error_code + from build_jobs where id = '${result.jobId}'`)) as any + expect(job).toHaveLength(1) + expect(job[0].status).toBe('succeeded') + expect(Number(job[0].attempt)).toBe(1) + expect(typeof job[0].started_ts).toBe('number') + expect(job[0].started_ts).toBeGreaterThan(0) + expect(typeof job[0].finished_ts).toBe('number') + expect(job[0].finished_ts).toBeGreaterThanOrEqual(job[0].started_ts) + expect(job[0].error_code).toBeNull() + + // build_input_snapshots 行:三哈希 + 主题 + config 引用 + 版本列真实值 + const snap = (await db.execute(`select content_snapshot_hash, route_snapshot_hash, + asset_manifest_hash, site_config_revision_id, theme_key, theme_version, node_version, + pnpm_version, astro_version, locale, timezone, manifest_path, + extract(epoch from build_timestamp)::float8 as build_ts + from build_input_snapshots where build_job_id = '${result.jobId}'`)) as any + expect(snap).toHaveLength(1) + const s = snap[0] + const manifest = JSON.parse(await readFile(join(result.inputDir, 'render-manifest.json'), 'utf8')) + 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(测试独立重算对拍) + 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 config = (await db.execute(`select scr.id from site_config_revisions scr + where scr.site_id = '${siteId}'`)) as any + expect(s.site_config_revision_id).toBe(config[0].id) + expect(s.theme_key).toBe('pilot-minimal') + expect(typeof s.theme_version).toBe('string') + expect(s.theme_version).not.toBe('') + // 真实读 process.versions(不硬编码) + expect(s.node_version).toBe(process.versions.node) + expect(typeof s.pnpm_version).toBe('string') + expect(s.pnpm_version).not.toBe('') + expect(s.locale).toBe('en') + expect(s.timezone).not.toBe('') + expect(s.manifest_path).toBe(join(result.inputDir, 'render-manifest.json')) + expect(typeof s.build_ts).toBe('number') + expect(s.build_ts).toBeGreaterThan(0) + + // 报告落盘 + const report = JSON.parse(await readFile(result.reportPath, 'utf8')) + expect(report.jobId).toBe(result.jobId) + expect(report.status).toBe('succeeded') + + // 幂等复用:同 published 集合 + 同主题版本 → 同 key 复用既有 job(不重跑) + const again = await createBuildJob(db, { + siteKey: key, + artifactsRoot: join(w.work, 'artifacts'), + }) + expect(again.reused).toBe(true) + expect(again.jobId).toBe(result.jobId) + expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${siteId}'`)).toBe(1) + } finally { + await cleanupSiteRows(db, siteId) + } + } finally { + await rm(w.work, { recursive: true, force: true }) + } + }) + }) + + it('六文件物化:site/config v1、content(published+media)、taxonomies(source_id 原样)、routes(redirect 并入+查询形态)、assets(实体路径解析)、render-manifest(版本块+计数+哈希)', async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-build-files') + try { + const { key, siteId } = await seedPublishedSite(db, w) + try { + const result = await createBuildJob(db, { + siteKey: key, + artifactsRoot: join(w.work, 'artifacts'), + }) + const inputDir = result.inputDir + // 六文件全在(RENDER_SNAPSHOT_FILES 冻结清单) + for (const name of ['site.json', 'content.json', 'taxonomies.json', 'routes.json', 'assets.json', 'render-manifest.json']) { + expect(existsSync(join(inputDir, name))).toBe(true) + } + const files = await readInputFiles(inputDir) + + // site.json = config v1 数据(Task 1 首版生成的 {name,origin,locale,brand}) + expect(files.site).toStrictEqual({ + name: key, + origin: `https://${key}.example`, + locale: 'en', + brand: {}, + }) + + // content.json:3 条 published 修订(typeKey/slug 排序钉死);每条 data 全量 + + // revisionId/checksum 与库内 published 修订对拍;slug 原样 + const dbRows = plain( + (await db.execute(`select ce.id as content_id, ce.type_key, el.locale, el.slug as locale_slug, + cr.id as revision_id, cr.checksum, cr.data + from entry_locales el + join content_entries ce on ce.id = el.content_entry_id + join content_revisions cr on cr.id = el.published_revision_id + where el.site_id = '${siteId}'`)) as any, + ) + expect(dbRows).toHaveLength(3) + expect(files.content.map((c: any) => [c.typeKey, c.slug])).toStrictEqual([ + ['basic_page', 'about-us'], + ['news_article', 'fixture-news-a'], + ['product', 'fixture-product'], + ]) + for (const entry of files.content) { + const row = dbRows.find((r: any) => r.revision_id === entry.revisionId) + expect(row).toBeDefined() + expect(entry.contentId).toBe(row.content_id) + expect(entry.locale).toBe('en') + expect(entry.checksum).toBe(row.checksum) + // data 全量对拍(bodyHtml 除外——URL 重写另测) + const { bodyHtml: _ignored, ...rest } = entry.data + const { bodyHtml: _dbIgnored, ...dbRest } = JSON.parse(JSON.stringify(row.data)) + expect(rest).toStrictEqual(dbRest) + expect(typeof entry.data.bodyHtml).toBe('string') + } + + // content.json media 引用面:published 修订的 media_usages(fieldPath → public_path) + const newsEntry = files.content.find((c: any) => c.slug === 'fixture-news-a') + const productEntry = files.content.find((c: any) => c.slug === 'fixture-product') + const aPub = '/wp-content/uploads/2026/10/fixture-a.png' + const bPub = '/wp-content/uploads/2026/10/fixture-b.png' + expect(newsEntry.media).toStrictEqual({ + 'attachmentRefs.featured': aPub, + 'data.bodyHtml.img[0]': aPub, + }) + expect(productEntry.media).toStrictEqual({ 'attachmentRefs.featured': bPub }) + + // taxonomies.json:双源全量;source_id 原样(wp:term 的 '31'/'32' 字符串) + expect(files.taxonomies.map((t: any) => [t.key, t.objectType, t.publicBasePath])).toStrictEqual([ + ['category', 'post', '/category/'], + ['product_cat', 'product', '/product-category/'], + ]) + const company = files.taxonomies[0].terms[0] + expect({ sourceId: company.sourceId, slug: company.slug, name: company.name, parentTermId: company.parentTermId }) + .toStrictEqual({ sourceId: '31', slug: 'company', name: 'Company', parentTermId: null }) + expect(company.contentEntryIds).toStrictEqual([newsEntry.contentId]) + const pipeFittings = files.taxonomies[1].terms[0] + expect(pipeFittings.sourceId).toBe('32') + expect(pipeFittings.contentEntryIds).toStrictEqual([]) + + // routes.json:html+asset+redirect 并入(含查询形态 redirect 与 redirect 字段面) + expect(files.routes.map((r: any) => [r.routePath, r.routeKind, r.queryKey])).toStrictEqual([ + ['/', 'html', null], + ['/', 'redirect', 'attachment_id=201'], + ['/', 'redirect', 'attachment_id=202'], + ['/about-us/', 'html', null], + ['/category/company/', 'html', null], + ['/news/', 'html', null], + ['/news/fixture-news-a.html', 'html', null], + ['/product-category/pipe-fittings/', 'html', null], + ['/products/', 'html', null], + ['/products/fixture-product/', 'html', null], + ['/wp-content/uploads/2026/10/fixture-a.png', 'asset', null], + ['/wp-content/uploads/2026/10/fixture-b.png', 'asset', null], + ]) + const homeHtml = files.routes[0] + expect({ queryPolicy: homeHtml.queryPolicy, indexable: homeHtml.indexable, priority: homeHtml.priority, verificationStatus: homeHtml.verificationStatus }) + .toStrictEqual({ queryPolicy: 'ignore', indexable: true, priority: 1000, verificationStatus: 'verified_html' }) + const redirectA = files.routes[1] + expect({ queryPolicy: redirectA.queryPolicy, targetStatus: redirectA.targetStatus, redirectStatus: redirectA.redirectStatus, redirectLocation: redirectA.redirectLocation, indexable: redirectA.indexable }) + .toStrictEqual({ queryPolicy: 'exact', targetStatus: '301', redirectStatus: 301, redirectLocation: aPub, indexable: false }) + const newsHtml = files.routes.find((r: any) => r.routePath === '/news/fixture-news-a.html') + expect(newsHtml.targetContentId).toBe(newsEntry.contentId) + + // assets.json:ready 资产实体路径解析(register 快照 metadata.storageRoot+storage_path) + const bundle = plain((await db.execute(`select storage_path from source_snapshots + where site_id = '${siteId}' and kind = 'media_bundle'`)) as any)[0] + expect(files.assets).toHaveLength(2) + const assetA = files.assets[0] + expect(assetA.publicPath).toBe(aPub) + expect(assetA.storagePath).toBe(`${bundle.storage_path}/2026/10/fixture-a.png`) + expect(assetA.copyPath).toBe(resolve(w.storageRoot, assetA.storagePath)) + expect(existsSync(assetA.copyPath)).toBe(true) + expect(assetA.sha256).toBe(sha256of(w.pngA)) + expect(assetA.detectedMimeType).toBe('image/png') + expect(Number(assetA.byteSize)).toBe(w.pngA.length) + expect(assetA.width).toBeNull() + expect(assetA.height).toBeNull() + expect(assetA.altText).toBeNull() + + // render-manifest:版本四常量 + counts + 三哈希 + 重写统计 + expect(files['render-manifest'].versions).toStrictEqual({ + adapter: ADAPTER_VERSION, + mapping: MAPPING_VERSION, + sanitizer: SANITIZER_VERSION, + parser: PARSER_VERSION, + }) + expect(files['render-manifest'].counts).toStrictEqual({ + contents: 3, + taxonomies: 2, + routes: 12, + assets: 2, + }) + expect(files['render-manifest'].sourceHashes).toStrictEqual(result.sourceHashes) + expect(files['render-manifest'].urlRewrite).toStrictEqual({ rewritten: 1, unresolved: 1 }) + expect(typeof files['render-manifest'].generatedAt).toBe('string') + } finally { + await cleanupSiteRows(db, siteId) + } + } finally { + await rm(w.work, { recursive: true, force: true }) + } + }) + }) + + it('bodyHtml URL 重写:media ready 命中 → 根相对 public_path;未命中保留原样;render-manifest 统计', async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-build-rewrite') + try { + const { key, siteId } = await seedPublishedSite(db, w) + try { + const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') }) + const files = await readInputFiles(result.inputDir) + const news = files.content.find((c: any) => c.slug === 'fixture-news-a') + + // 库内 published 修订的 bodyHtml(重写前真相):绝对 URL img + 根相对内链 a + const dbBody = plain((await db.execute(`select cr.data->>'bodyHtml' as body from content_revisions cr + where cr.id = (select 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')`)) as any)[0].body + expect(dbBody).toContain('https://fixture.example/wp-content/uploads/2026/10/fixture-a.png') + // 重写:命中 ready 集合(剥 host 按 path 比对 public_path)→ 根相对 + expect(news.data.bodyHtml).toBe( + dbBody.replace( + 'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png', + '/wp-content/uploads/2026/10/fixture-a.png', + ), + ) + expect(news.data.bodyHtml).not.toContain('https://fixture.example/wp-content') + // 未命中的根相对内链保留原样(不虚构目标) + expect(news.data.bodyHtml).toContain('href="/products/fixture-product/"') + // 统计入 render-manifest(rewritten=1 img;unresolved=1 内链 a) + expect(files['render-manifest'].urlRewrite).toStrictEqual({ rewritten: 1, unresolved: 1 }) + } finally { + await cleanupSiteRows(db, siteId) + } + } finally { + await rm(w.work, { recursive: true, force: true }) + } + }) + }) + + it('canonical JSON 确定性:同输入两次物化 → 六文件字节一致(键排序、无空白)', async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-build-det') + try { + const { siteId } = await seedPublishedSite(db, w) + try { + const fixedAt = '2026-10-22T00:00:00.000Z' + const first = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) + const second = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) + // 六文件逐字节一致 + expect(second.files).toStrictEqual(first.files) + // canonical 形态:键递归排序、无空白(文件串 = canonicalJson(解析值) + 尾换行) + for (const name of Object.keys(first.files) as Array) { + const content = first.files[name] + expect(content).toBe(`${canonicalJson(JSON.parse(content))}\n`) + expect(content).not.toMatch(/,\s|:\s/) + } + expect(first.sourceHashes).toStrictEqual(second.sourceHashes) + } finally { + await cleanupSiteRows(db, siteId) + } + } finally { + await rm(w.work, { recursive: true, force: true }) + } + }) + }) + + it('NOT_PUBLISHED:未 publish 站点 → 明确报错且零 build_jobs 行;SITE_NOT_FOUND 通道', async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-build-np') + try { + // register+import 但不 publish + const key = `sfbuild-${rand()}` + const reg = await registerSource(db, { + siteKey: key, + wxrPath: WXR_FIXTURE, + mediaDir: join(w.work, 'media'), + allowedRoots: w.allowedRoots, + storageRoot: w.storageRoot, + reportRoot: join(w.work, 'artifacts', 'register'), + }) + try { + await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') }) + const err = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') }).then( + () => { + throw new Error('不应成功') + }, + (e: unknown) => e, + ) + expect(err).toBeInstanceOf(BuildError) + expect((err as BuildError).code).toBe('NOT_PUBLISHED') + // 拒绝发生在任何 job 行之前(快照只读 published——空集无物化意义) + expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${reg.siteId}'`)).toBe(0) + } finally { + await cleanupSiteRows(db, reg.siteId) + } + + const notFound = await createBuildJob(db, { siteKey: 'no-such-site' }).then( + () => { + throw new Error('不应成功') + }, + (e: unknown) => e, + ) + expect(notFound).toBeInstanceOf(BuildError) + expect((notFound as BuildError).code).toBe('SITE_NOT_FOUND') + } finally { + await rm(w.work, { recursive: true, force: true }) + } + }) + }) + + it('ADR-010 双修订:发布 A → 再导入改内容 current=B → 新 key 再 build → content.json 仍为 A(published 冻结)', async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-build-adr010') + try { + const { key, siteId } = await seedPublishedSite(db, w) + try { + // 第一次 build:published=A 物化 + const first = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') }) + expect(first.reused).toBe(false) + + // 改内容再导入:post 101 正文加 V2 标记 → 新修订 B、current=B(published 仍 A) + const wxrText = await readFile(WXR_FIXTURE, 'utf8') + const v2Path = join(w.work, 'mini-v2.xml') + await writeFile(v2Path, wxrText.replace('Body with ', 'V2-BODY Body with '), 'utf8') + await registerSource(db, { + siteKey: key, + wxrPath: v2Path, + mediaDir: join(w.work, 'media'), + allowedRoots: w.allowedRoots, + storageRoot: w.storageRoot, + reportRoot: join(w.work, 'artifacts', 'register'), + }) + const reimport = await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') }) + expect(reimport.status).toBe('succeeded_with_issues') + // 双指针分叉:current=B ≠ published=A(ADR-010) + const forked = plain((await db.execute(`select count(*)::int as n from entry_locales + where site_id = '${siteId}' and current_revision_id is distinct from published_revision_id`)) as any)[0].n + expect(Number(forked)).toBe(1) + + // 新幂等键强制新 job(默认键按 published 集合派生会复用——复用语义另测) + const second = await createBuildJob(db, { + siteKey: key, + artifactsRoot: join(w.work, 'artifacts'), + idempotencyKey: `build:test:adr010:${rand()}`, + }) + expect(second.reused).toBe(false) + expect(second.jobId).not.toBe(first.jobId) + + // content.json 仍为 A:字节级等于第一次物化(published 冻结语义) + const contentA = await readFile(join(first.inputDir, 'content.json'), 'utf8') + const contentB = await readFile(join(second.inputDir, 'content.json'), 'utf8') + expect(contentB).toBe(contentA) + expect(contentB).not.toContain('V2-BODY') + expect(contentB).toContain('fixture-news-a') + // 哈希同源:同 published 集合 → content 哈希一致(可复现性锚点) + expect(second.sourceHashes.content).toBe(first.sourceHashes.content) + } finally { + await cleanupSiteRows(db, siteId) + } + } finally { + await rm(w.work, { recursive: true, force: true }) + } + }) + }) +}) + +describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双源)', () => { + const A = '/wp-content/uploads/2026/10/a.png' + const index = buildMediaPathIndex([ + { publicPath: A, sourceUrl: 'https://old.example.com/wp-content/uploads/2026/10/a.png' }, + ]) + + it('绝对 URL 剥 host 命中 → 根相对 public_path', () => { + const r = rewriteBodyHtml('', index) + expect(r.html).toBe(``) + expect(r.stats).toStrictEqual({ rewritten: 1, unresolved: 0 }) + }) + + it('协议相对与根相对变体同键命中;查询串剥离', () => { + for (const url of [ + '//old.example.com/wp-content/uploads/2026/10/a.png', + `${A}?ver=9`, + 'https://old.example.com/wp-content/uploads/2026/10/a.png?ver=9#frag', + ]) { + const r = rewriteBodyHtml(``, index) + expect(r.html).toBe(``) + expect(r.stats.rewritten).toBe(1) + } + }) + + it('source_url 路径段双源命中(public_path 之外的注册 URL 形态)', () => { + const idx = buildMediaPathIndex([{ publicPath: '/files/x.jpg', sourceUrl: 'https://cdn.old.net/files/x.jpg' }]) + const r = rewriteBodyHtml('x', idx) + expect(r.html).toBe('x') + expect(r.stats.rewritten).toBe(1) + }) + + it('未命中(含外域)保留原样 + unresolved 计数;data:/片段/相对形态不计', () => { + const r = rewriteBodyHtml( + 'ptr', + index, + ) + expect(r.html).toBe( + 'ptr', + ) + expect(r.stats).toStrictEqual({ rewritten: 0, unresolved: 1 }) + }) +})