feat(import): 全链编排与导入报告

Task 6(Plan 3):四家写入器汇流为默认编排管道。

- import.service:注入 events/pipeline 缺席时走真实事件流(runWxrAdapter
  惰性 async generator,mapConfig.locale=site.default_locale 前置事实 8);
  分派严格串行 content→term→media→route(禁 Promise.all);finish 硬序
  term(sync)→content(sync)→media(await)→route(await);counts 由 ⊕ 覆盖
  改为逐 key 求和聚合;新增 stale 回收(同站点 queued/running >24h 一条
  SQL 置 stale);报告新增 paths/productsPath+newsPath、issueGroups 六组、
  sanitize 清洗统计(事件流 changedRows/byDiffType 聚合)、adapterTiming。
- content-writer:NULL-entry 收编(快照行在而 content_entry 空,五列先验
  三分支:无先验新建挂接/同 checksum 挂接 skipped/异 checksum 挂接新版本)。
- route-writer:attachment 降级汇总按去重 sourceId 计(重跑仍必现);
  normalizeListPath 导出;writeAttachmentRoute 返回 written/resolved/blocked。
- 四家写入器 identity 收敛共享基类型 WriterContext =
  Pick<ImportJobContext,'db'|'siteId'|'connectionId'>(type-only import,
  无运行时环依赖)。
- CLI import 增 --products-path/--news-path(前置事实 9,缺省 /products/
  /news/)记入 job 报告。
- 测试:tests/integration/import-full.test.ts 6 例(全链 zh-CN 接线证明
  +published 全 NULL/报告四域计数+sanitize+分组/幂等重跑零写语义/
  无 media 快照域缺失不失败/NULL-entry 收编/stale 回收/REPORT_WRITE_FAILED
  遗留补测);import-job 契约测试钉死 counts 求和(100⊕3=103)。
  全量 323 通过;typecheck/lint 干净。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 20:08:06 +08:00
parent 27a8282367
commit 8003de336b
8 changed files with 856 additions and 89 deletions
+65 -9
View File
@@ -50,7 +50,7 @@ import { UNIVERSAL_ROW_VERSION, type UniversalRow } from '@sf/contracts'
import type { AdapterEvent } from '@sf/adapter-wp'
import { canonicalJson } from '../../common/canonical.js'
import { contentEntries, contentRevisions, entryLocales, sourceRecords } from '../../db/schema.js'
import type { ImportDb, ImportPipeline, ImportPipelineOutcome, ImportVersionBlock } from './import.service.js'
import type { ImportDb, ImportPipeline, ImportPipelineOutcome, ImportVersionBlock, WriterContext } from './import.service.js'
/** 事务句柄类型(db.transaction 回调参数——从 ImportDb 的事务签名提取,避免重复泛型展开) */
type Tx = Parameters<Parameters<ImportDb['transaction']>[0]>[0]
@@ -149,18 +149,15 @@ interface ContentWriterCounts {
* - contentEntries:新建 content_entries 数(幂等重跑/变化路径为 0);
* - revisionsCreated:新建 content_revisions 数(checksum 命中为 0——G2 验收锚点);
* - sourceRecords:新建 source_records 数(内容/attachment/unsupported/error 全计;
* 跨快照重导按快照补行故可为正);
* 跨快照重导按快照补行故可为正;NULL-entry 占位行收编**不计**——行已在,UPDATE
* 挂接非新建,见 handleContentRow 态 1b);
* - skipped:内容行未产修订的跳过数(同快照 checksum 命中、跨快照无业务变化、locale
* 拒收三类;attachment 命中不计——媒体域是 Task 4 主场);
* - unsupportedRecords/failedRecords:unsupported/error 事件分项数(**按事件计**非按
* 插入行——重跑行幂等而计数如实;拆分只在分派点可知,Task 6 报告层无从重derive)。
* issues:locale 不匹配、未知 sourceType 兜底——显式报告(§7.6 禁静默)。
*/
export function createContentEventHandler(identity: {
db: ImportDb
siteId: string
/** 快照行六列身份的 connection 侧 + 五列内容身份的解析锚点 */
connectionId: string
export function createContentEventHandler(identity: WriterContext & {
/** 本 job 的 wxr 快照 id(六列身份的 snapshot 侧 + 新行 source_snapshot_id) */
snapshotId: string
/** 本 job id(source_records.import_job_id 与 content_revisions.import_job_id 落库锚点) */
@@ -286,8 +283,6 @@ export function createContentEventHandler(identity: {
// 态 1:本快照行已存在(挂 entry——本写入器是 content 行 source_records 的唯一写入方,
// 挂接不变量由写入路径自身保证)。
// 已知死角:NULL-entry 占位行(外源 seed/未来写入方)+ 后续映射扩充重导 → 全新建分支
// 插入撞六列唯一约束 23505;Task 6 收编(UPDATE 该行挂 entry)——见审查记录
const snapRow = await findSnapshotRecord(row)
if (snapRow !== null && snapRow.contentEntryId !== null) {
if (snapRow.normalizedChecksum === checksum) {
@@ -315,6 +310,67 @@ export function createContentEventHandler(identity: {
counts.revisionsCreated++
return
}
if (snapRow !== null) {
// 态 1b:NULL-entry 占位行收编(Task 6 义务①——Task 3 审查移交的已知死角在此闭合):
// 六列命中而 content_entry_id NULL 的行(外源 seed / attachment record-only 足迹 +
// 映射扩充后该 sourceType 升格为内容类型重导)→ 走全新建分支会撞六列唯一约束
// 23505,改为就地 UPDATE 挂 entry。语义与态 2/3 对齐:
// - 无五列先行者 → 全新建变体(entry+revision+locale),占位行 UPDATE 挂接;
// - 先行者 checksum 同 → 只挂接(skipped——0 新修订);
// - 先行者 checksum 异 → 挂接 + 新修订 + current 前移。
// counts:sourceRecords 不计(行已在,收编非新建);lineage 留在占位行建行 job。
const prior = await findPriorLinkedRecord(row)
const adoptValues = { contentEntryId: null as string | null, normalizedChecksum: checksum, sourceUrl: row.sourceUrl ?? null }
if (prior === null) {
await db.transaction(async (tx) => {
const entry = await tx
.insert(contentEntries)
.values({ siteId, typeKey, lifecycleStatus: 'active' })
.returning({ id: contentEntries.id })
const entryId = entry[0]!.id
await tx
.update(sourceRecords)
.set({ ...adoptValues, contentEntryId: entryId })
.where(eq(sourceRecords.id, snapRow.id))
const revisionId = await insertRevision(tx, {
entryId,
row,
data,
checksum,
sourceRecordId: snapRow.id,
})
await pointCurrent(tx, entryId, row, revisionId)
})
counts.contentEntries++
counts.revisionsCreated++
return
}
const entryId = prior.contentEntryId // 事务回调外的 const 收窄(属性收窄不进闭包)
if (prior.normalizedChecksum === checksum) {
await db
.update(sourceRecords)
.set({ ...adoptValues, contentEntryId: entryId })
.where(eq(sourceRecords.id, snapRow.id))
counts.skipped++
return
}
await db.transaction(async (tx) => {
await tx
.update(sourceRecords)
.set({ ...adoptValues, contentEntryId: entryId })
.where(eq(sourceRecords.id, snapRow.id))
const revisionId = await insertRevision(tx, {
entryId,
row,
data,
checksum,
sourceRecordId: snapRow.id,
})
await pointCurrent(tx, entryId, row, revisionId)
})
counts.revisionsCreated++
return
}
// 态 2/3 前置:内容身份(五列)解析——跨快照复用既有 entry(permalink/快照变化不建新)
const prior = await findPriorLinkedRecord(row)