feat(import): job 骨架与生命周期

Plan 3 Task 1:sf import 命令骨架 + import_jobs 生命周期(空管道,Task 6 接真实管道)。

- import.service.ts(零装饰器):createImportJob——站点/快照解析(缺省按 (connection,
  kind='wxr') 选最新;三快照绑定按前置事实 13 记入报告,job 行不扩列)、幂等建行
  (默认 key=site+wxr快照+三语义版本;重复提交复用既有 job 不重跑,并发撞唯一约束
  重查收敛)、状态推进 queued→running→succeeded/failed(attempt/started_at/finished_at/
  error_code/error_summary)、报告落 artifacts/import/<jobId>/reports/import.json
  (全四版本块:adapter/mapping/sanitizer/parser;job 行落 mapping_version)
- 可预期错误带 code:SITE_NOT_FOUND / SNAPSHOT_NOT_FOUND / NO_WXR_SNAPSHOT /
  PIPELINE_FAILED——解析期失败零 job 行
- adapter 事件加法式补 postId(前置事实 12):unsupported/error 事件可选携带
  String(postId),缺失时键缺席不硬造 source_id;row 事件与报告聚合零影响、不触发版本 bump
- canonicalJson 提取至 apps/api/src/common/canonical.ts 共享导出(前置事实 6),
  register 侧改 import 零行为变更(既有清单哈希测试守护)
- sf CLI + import 命令(--site/--snapshot/--key/--connection),输出 job 摘要与版本块
- 测试:tests/integration/import-job.test.ts(5 例:生命周期观察/最新快照解析/幂等复用
  与并发收敛/可预期失败零落行/failed 路径)+ adapter postId 3 例 + canonical 单测 6 例
  (265→279 全绿);typecheck 四段链/lint 绿;临时库 CLI 冒烟(register→import→复用)通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 15:30:42 +08:00
parent e52a2eb3e4
commit 0e8b2b5356
10 changed files with 1052 additions and 28 deletions
+46
View File
@@ -0,0 +1,46 @@
// canonicalJson 共享导出测试(Plan 3 Task 1,前置事实 6):从 register.service.ts 提取到
// apps/api/src/common/canonical.ts——register 侧改 import 零行为变更(既有 media_bundle 清单
// 哈希测试继续绿的回归责任在 tests/integration/source-register.test.ts,此处钉导出本身的
// 序列化定义:对象键递归排序、无空白、数组保序)。
// 独立重算对照:期望串手写钉死,不 import 实现——定义漂移即爆红。
import { describe, expect, it } from 'vitest'
import { canonicalJson } from '../../apps/api/src/common/canonical.js'
describe('canonicalJson(共享导出,前置事实 6)', () => {
it('对象键递归排序:任意嵌套层级都按键名字典序', () => {
expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}')
expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe('{"a":{"c":3,"d":2},"b":1}')
expect(canonicalJson({ z: { y: { x: { w: 1, v: 2 } } } })).toBe('{"z":{"y":{"x":{"v":2,"w":1}}}}')
})
it('无空白:键与值、数组元素间均无空格/换行', () => {
expect(canonicalJson({ a: [1, 2, 3], b: { c: 'x' } })).toBe('{"a":[1,2,3],"b":{"c":"x"}}')
expect(canonicalJson({ a: 1, b: 2 })).not.toContain(' ')
expect(canonicalJson({ a: { b: { c: [1, { d: 2 }] } } })).not.toMatch(/\s/)
})
it('数组保序:元素顺序是语义的一部分,不排序', () => {
expect(canonicalJson([3, 1, 2])).toBe('[3,1,2]')
expect(canonicalJson([{ z: 1, a: 2 }, { y: 3, b: 4 }])).toBe('[{"a":2,"z":1},{"b":4,"y":3}]')
})
it('原始值走 JSON.stringify 语义:字符串/数字/布尔/null', () => {
expect(canonicalJson('x')).toBe('"x"')
expect(canonicalJson('a"b')).toBe('"a\\"b"')
expect(canonicalJson(3)).toBe('3')
expect(canonicalJson(true)).toBe('true')
expect(canonicalJson(null)).toBe('null')
expect(canonicalJson({ a: null, b: true, c: 0, d: '' })).toBe('{"a":null,"b":true,"c":0,"d":""}')
})
it('键排序按 UTF-16 码元(与 Array.prototype.sort 默认一致)——大写在前、非 ASCII 原样', () => {
expect(canonicalJson({ b: 1, A: 2, a: 3 })).toBe('{"A":2,"a":3,"b":1}')
expect(canonicalJson({ 图: 1, 中: 2 })).toBe(JSON.stringify({ 中: 2, 图: 1 }))
})
it('空对象/空数组序列化', () => {
expect(canonicalJson({})).toBe('{}')
expect(canonicalJson([])).toBe('[]')
expect(canonicalJson({ a: {}, b: [] })).toBe('{"a":{},"b":[]}')
})
})