feat(build): 冻结 Render Snapshot 物化与 build job

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/<id>/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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 07:46:23 +08:00
parent 6d681f24ef
commit 8f42c838c1
5 changed files with 1575 additions and 6 deletions
+53 -6
View File
@@ -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>', '站点 key')
.option('--key <key>', '幂等键(缺省 = site+published 集合哈希+主题版本 自动派生;重复提交同 key 复用既有 job 不重跑)')
.option('--artifacts <dir>', '产物根目录(缺省 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<void> } }).$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)}`)
}