2026-10-20 16:45:05 +08:00
# SiteForge 纵向切片 · Plan 1:基线与工程骨架(阶段 0 + 阶段 1)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 建立切片的不可变基线(备份、夹具、真实数据基数)与可长期演进的工程骨架(pnpm monorepo、PostgreSQL、Drizzle 迁移、全部核心表与数据库级约束、冻结契约包),验收 = 空环境一条命令起库并迁移、所有约束有自动测试。
**Architecture:** 依据设计基线 `docs/superpowers/specs/2026-10-20-siteforge-vertical-slice-design-v2.md` §4/§5/§15(阶段 0– 1)。monorepo: `packages/contracts` (纯 TS 契约 + 冻结测试)、`apps/api` ( NestJS standalone + Drizzle schema/迁移)、`infra/` ( PG16 compose,宿主端口 **5434** ,避开 cms 项目已占用的 5433)。数据库约束即规格:不可变触发器、NULLS NOT DISTINCT 唯一、稳定身份键全部落数据库并被测试锁定。
**Tech Stack:** Node 22 LTS、pnpm workspace、TypeScript strict、Vitest、PostgreSQL 16( Docker)、Drizzle ORM + drizzle-kit、NestJS( standalone context)。
**计划系列说明:** 本计划是切片系列的第 1 份(阶段 0+1)。阶段 2(WXR 适配器)、3(导入管道)、4(发布/快照/主题)、5(门禁/预览)、6(真实 L3 验收)在各自前置完成后另立计划,避免单份计划失控。
**Worktree 说明:** cms-one 是本项目专属新仓库,无并行工作,直接在 `main` 分支实施(不另建 worktree)。
---
## 前置事实(执行者必读)
1. **不改动 `D:\project\cms`** :只读。备份是"读出来写到别处",也不算改动。
2. **PG 端口** : cms 项目 compose 占用宿主 5433;本项目用 **5434** 。
2026-10-20 16:50:01 +08:00
3. **真实数据不入 git** : `.gitignore` 已忽略 `storage/` 、`artifacts/` 、`.env*` (已加 `!.env.example` 反排除,示例文件可提交)。夹具必须脱敏手写,不从真实 WXR 复制内容。
2026-10-20 16:45:05 +08:00
4. **Windows 环境** :路径分隔符、长路径(媒体全角文件名)注意;命令以 Git Bash 为准。
5. 上位规格字段定义见 spec §5(表结构原文)、§7.3( UniversalRow)、§12.1( Route Manifest)、§9.1( Theme Manifest)——本计划代码与之一致,冲突时以 spec 为准并回报。
---
2026-10-20 17:23:28 +08:00
### Task 0.1: 环境版本基线记录 ✅ 完成(commit d64a035,规格审查通过)
> 编排注(2026-10-20):审查发现 cms 为**活跃仓库**(并行会话在继续提交),其基线降级为信息性记录;本项目不变量 = 绝不向 cms 写入(纪律),而非 cms 状态不变。
2026-10-20 16:45:05 +08:00
**Files:**
- Create: `docs/evidence/env-baseline.md`
- [ ] **Step 1: 验证环境版本**
Run: `node -v && pnpm -v && docker --version && docker compose version`
Expected: node ≥ v22.x; pnpm ≥ 9.x; docker 与 compose 可用。**若 node < 22 停止并报告用户。**
2026-10-20 16:50:01 +08:00
- [ ] **Step 2: 记录 cms 只读基线(验收对照用)**
Run: `git -C D:/project/cms rev-parse HEAD && git -C D:/project/cms status --porcelain`
把两段输出**原样**存入 `docs/evidence/env-baseline.md` (注:cms 工作树可能存在既有未提交改动,如 `about.astro` ——这不是我们造成的,照记不误。后续验收以"与本基线一致"为准;**发现任何差异只报告用户,绝不执行 restore/checkout**)。
- [ ] **Step 3: 验证 gitignore 覆盖**
Run: `git check-ignore -v storage artifacts .env; git add --dry-run .env.example`
Expected: 前三者各命中一条忽略规则;`.env.example` 反排除生效、dry-run add 成功。输出记入 env-baseline.md。
- [ ] **Step 4: 写基线文档**
2026-10-20 16:45:05 +08:00
```markdown
# docs/evidence/env-baseline.md
- 日期:(填)
- Git commit: (git rev-parse --short HEAD 输出)
- node: (版本)
- pnpm: (版本)
- docker / compose: (版本)
- PostgreSQL 镜像:postgres:16(固定 tag,不用 latest)
```
2026-10-20 16:53:07 +08:00
- [ ] **Step 5: Commit**
2026-10-20 16:45:05 +08:00
```bash
git add docs/evidence/env-baseline.md
2026-10-20 16:53:07 +08:00
git commit -m "chore(evidence): 记录阶段0环境版本基线(含 cms 只读基线与 gitignore 验证)"
2026-10-20 16:45:05 +08:00
```
2026-10-20 16:53:07 +08:00
(注:Step 2/3 的记录输出并入 Step 4 的文档一并提交。)
2026-10-20 16:45:05 +08:00
---
2026-10-20 17:18:02 +08:00
### Task 0.2: cms 数据备份(第二存储)⏸ **用户暂缓(2026-10-20)——待备份目标盘就绪后补做,不阻塞后续任务**
2026-10-20 16:45:05 +08:00
**Files:** 无代码;产出备份与 `docs/evidence/backup-record.md`
2026-10-20 16:50:01 +08:00
- [ ] **Step 1: 向用户确认备份目标路径 BACKUP_DIR** (如 `E:\backups\cms-2026-10-20` ),**并同时确认试点数据的使用授权与敏感信息范围**(授权结论记入 backup-record.md)。未确认前跳过本任务后续步骤。
2026-10-20 16:45:05 +08:00
- [ ] **Step 2: 若 cms 的 PG 容器在运行,先停止(文件级拷贝一致性)**
Run: `docker ps --format '{{.Names}} {{.Ports}}' | grep 5433` (有输出则 `docker stop <name>` ,记录名字,备份后不负责重启——cms 本就冻结)
- [ ] **Step 3: 拷贝三块数据**
```bash
BACKUP_DIR = "<用户确认的路径>"
robocopy "D:\\project\\cms\\.data" " $BACKUP_DIR \\data" /E /MT:8 /R:2 /W:5
robocopy "D:\\project\\cms\\storage" " $BACKUP_DIR \\storage" /E /MT:8 /R:2 /W:5
robocopy "D:\\project\\cms\\infrastructure" " $BACKUP_DIR \\infrastructure" /E
```
- [ ] **Step 4: 记录备份哈希与大小**
```bash
du -sh " $BACKUP_DIR " /*
sha256sum "D:\\project\\cms\\storage\\wxr\\d0d7497e6db5b383.xml" " $BACKUP_DIR \\storage\\wxr\\d0d7497e6db5b383.xml"
sha256sum "D:\\project\\cms\\storage\\wxr\\fd04d6ba8a1316b6.xml" " $BACKUP_DIR \\storage\\wxr\\fd04d6ba8a1316b6.xml"
```
写入 `docs/evidence/backup-record.md` (位置、大小、WXR 哈希对照一致才算成功)。
- [ ] **Step 5: Commit(只提交记录文档)**
```bash
git add docs/evidence/backup-record.md && git commit -m "chore(evidence): cms 数据备份记录"
```
---
2026-10-20 17:30:25 +08:00
### Task 0.3: 脱敏最小夹具 ✅ 完成(commit 681163b + 修正 ee58 待查,规格审查通过;审查发现并修复计划中 PHP 序列化长度前缀笔误 s:7→s:8、s:12→s:10; 203/204 悬空图库引用为有意设计,已记入夹具 README)
2026-10-20 16:45:05 +08:00
**Files:**
- Create: `tests/fixtures/wxr/mini.xml`
- Create: `tests/fixtures/sitemap/mini-sitemap.xml`
- Create: `tests/fixtures/media/README.md` + 2 个 1× 1 PNG(占位)
- Create: `tests/fixtures/README.md`
- [ ] **Step 1: 手写最小 WXR 夹具** (全新虚构内容,含后续解析要用的全部结构变体)
```xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version= "2.0" xmlns:excerpt= "http://wordpress.org/export/1.2/excerpt/"
xmlns:content= "http://purl.org/rss/1.0/modules/content/"
xmlns:wfw= "http://wellformedweb.org/CommentAPI/"
xmlns:dc= "http://purl.org/dc/elements/1.1/"
xmlns:wp= "http://wordpress.org/export/1.2/" >
<channel>
<title> Fixture Site</title><link> https://fixture.example</link>
<wp:wxr_version> 1.2</wp:wxr_version>
<!-- 1 post with .html permalink, Yoast meta, thumbnail -->
<item>
<title> <![CDATA[Fixture News A]]> </title>
<link> https://fixture.example/news/fixture-news-a.html</link>
<pubDate> Mon, 05 Oct 2026 09:00:00 +0000</pubDate>
<dc:creator> <![CDATA[editor]]> </dc:creator>
<guid isPermaLink= "false" > https://fixture.example/?p=101</guid>
<content:encoded> <![CDATA[<p>Body with <a href="/products/fixture-product/">link</a> and <img src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png"/></p>]]> </content:encoded>
<excerpt:encoded> <![CDATA[Fixture excerpt A]]> </excerpt:encoded>
<wp:post_id> 101</wp:post_id>
<wp:post_date> <![CDATA[2026-10-05 09:00:00]]> </wp:post_date>
<wp:post_modified> <![CDATA[2026-10-06 10:00:00]]> </wp:post_modified>
<wp:post_name> <![CDATA[fixture-news-a]]> </wp:post_name>
<wp:post_type> <![CDATA[post]]> </wp:post_type>
<wp:status> <![CDATA[publish]]> </wp:status>
<category domain= "category" nicename= "company" > <![CDATA[Company]]> </category>
<wp:postmeta><wp:meta_key> <![CDATA[_thumbnail_id]]> </wp:meta_key><wp:meta_value> <![CDATA[201]]> </wp:meta_value></wp:postmeta>
<wp:postmeta><wp:meta_key> <![CDATA[_yoast_wpseo_title]]> </wp:meta_key><wp:meta_value> <![CDATA[Fixture SEO Title A]]> </wp:meta_value></wp:postmeta>
<wp:postmeta><wp:meta_key> <![CDATA[_yoast_wpseo_metadesc]]> </wp:meta_key><wp:meta_value> <![CDATA[Fixture SEO description A.]]> </wp:meta_value></wp:postmeta>
</item>
<!-- 2 product CPT with gallery + wppop_sections(PHP serialized) -->
<item>
<title> <![CDATA[Fixture Product]]> </title>
<link> https://fixture.example/products/fixture-product/</link>
<wp:post_id> 111</wp:post_id><wp:post_name> <![CDATA[fixture-product]]> </wp:post_name>
<wp:post_type> <![CDATA[product]]> </wp:post_type><wp:status> <![CDATA[publish]]> </wp:status>
<content:encoded> <![CDATA[<p>Product body</p>]]> </content:encoded>
<wp:postmeta><wp:meta_key> <![CDATA[_thumbnail_id]]> </wp:meta_key><wp:meta_value> <![CDATA[202]]> </wp:meta_value></wp:postmeta>
<wp:postmeta><wp:meta_key> <![CDATA[_product_image_gallery]]> </wp:meta_key><wp:meta_value> <![CDATA[203,204]]> </wp:meta_value></wp:postmeta>
2026-10-20 17:30:25 +08:00
<wp:postmeta><wp:meta_key> <![CDATA[wppop_sections]]> </wp:meta_key><wp:meta_value> <![CDATA[a:1:{i:0;a:2:{s:5:"title";s:8:"SpecsTab";s:7:"content";s:10:"<p>Tab</p>";}}]]> </wp:meta_value></wp:postmeta>
2026-10-20 16:45:05 +08:00
</item>
<!-- 3 page -->
<item>
<title> <![CDATA[Fixture About]]> </title><link> https://fixture.example/about-us/</link>
<wp:post_id> 121</wp:post_id><wp:post_name> <![CDATA[about-us]]> </wp:post_name>
<wp:post_type> <![CDATA[page]]> </wp:post_type><wp:status> <![CDATA[publish]]> </wp:status>
<content:encoded> <![CDATA[<p>About body</p>]]> </content:encoded>
</item>
<!-- 4 term -->
<wp:term>
<wp:term_id> <![CDATA[31]]> </wp:term_id><wp:term_taxonomy> <![CDATA[category]]> </wp:term_taxonomy>
<wp:term_slug> <![CDATA[company]]> </wp:term_slug><wp:term_name> <![CDATA[Company]]> </wp:term_name>
</wp:term>
<wp:term>
<wp:term_id> <![CDATA[32]]> </wp:term_id><wp:term_taxonomy> <![CDATA[product_cat]]> </wp:term_taxonomy>
<wp:term_slug> <![CDATA[pipe-fittings]]> </wp:term_slug><wp:term_name> <![CDATA[Pipe Fittings]]> </wp:term_name>
</wp:term>
<!-- 5 attachments -->
<item><title> <![CDATA[fixture-a]]> </title><link> https://fixture.example/?attachment_id=201</link>
<wp:post_id> 201</wp:post_id><wp:post_type> <![CDATA[attachment]]> </wp:post_type><wp:status> <![CDATA[inherit]]> </wp:status>
<wp:attachment_url> https://fixture.example/wp-content/uploads/2026/10/fixture-a.png</wp:attachment_url>
</item>
<item><title> <![CDATA[fixture-b]]> </title><link> https://fixture.example/?attachment_id=202</link>
<wp:post_id> 202</wp:post_id><wp:post_type> <![CDATA[attachment]]> </wp:post_type><wp:status> <![CDATA[inherit]]> </wp:status>
<wp:attachment_url> https://fixture.example/wp-content/uploads/2026/10/fixture-b.png</wp:attachment_url>
</item>
</channel>
</rss>
```
- [ ] **Step 2: sitemap 夹具**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns= "http://www.sitemaps.org/schemas/sitemap/0.9" >
<url><loc> https://fixture.example/</loc></url>
<url><loc> https://fixture.example/news/fixture-news-a.html</loc></url>
<url><loc> https://fixture.example/products/fixture-product/</loc></url>
<url><loc> https://fixture.example/about-us/</loc></url>
</urlset>
```
- [ ] **Step 3: 媒体占位** : `tests/fixtures/media/` 下放 `fixture-a.png` 、`fixture-b.png` ( 1× 1 PNG 字节,可用 base64 解码生成),README 注明"虚构占位,命名与 WXR attachment_url 对应"。
- [ ] **Step 4: 夹具 README** (来源声明:全部虚构;与真实 hesupipe 数据无关;阶段 2 解析器测试以此为标准输入)。
- [ ] **Step 5: Commit**
```bash
git add tests/fixtures && git commit -m "test(fixtures): 脱敏最小 WXR/sitemap/媒体夹具(全虚构)"
```
---
2026-10-20 17:34:28 +08:00
### Task 0.4: 真实 WXR 输入基数记录(只读 cms)✅ 完成(commit 2ee8485,规格审查通过——台账四类全部吻合;75 条台账外类型已记录,留阶段 6 范围决策)
2026-10-20 16:45:05 +08:00
**Files:**
- Create: `docs/evidence/real-wxr-baseline.md`
- [ ] **Step 1: 统计 hesupipe WXR 基数**
```bash
WXR = "D:/project/cms/storage/wxr/fd04d6ba8a1316b6.xml"
grep -c "<item>" " $WXR "
grep -o "<wp:post_type><!\[CDATA\[[^]]*\]\]></wp:post_type>" " $WXR " | sort | uniq -c
grep -c "<wp:term>" " $WXR "
sha256sum " $WXR " ; stat -c %s " $WXR "
```
- [ ] **Step 2: 结果写入文档** (含命令原文,作为阶段 6 L3 验收的对照基数;台账预期约:129 post + 47~48 product + 15 page + 855 attachment,以实际输出为准)。
- [ ] **Step 3: Commit**
```bash
git add docs/evidence/real-wxr-baseline.md && git commit -m "chore(evidence): hesupipe WXR 输入基数记录"
```
---
### Task 1: pnpm workspace 与测试冒烟
**Files:**
- Create: `package.json` (根)、`pnpm-workspace.yaml` 、`tsconfig.base.json` 、`vitest.config.ts`
- Create: `tests/unit/smoke.test.ts`
- [ ] **Step 1: 写失败测试**
```ts
// tests/unit/smoke.test.ts
import { describe , it , expect } from 'vitest'
describe ( 'workspace smoke' , () => {
it ( 'vitest runs' , () => { expect ( 1 + 1 ). toBe ( 2 ) })
})
```
- [ ] **Step 2: 初始化 workspace 文件**
```json
// package.json
{
"name" : "siteforge" ,
"private" : true ,
"packageManager" : "pnpm@<Task0.1记录的版本>" ,
"scripts" : {
"test" : "vitest run" ,
"typecheck" : "tsc -p tsconfig.json --noEmit" ,
"lint" : "eslint ."
},
"devDependencies" : {
"typescript" : "^5.6.0" ,
"vitest" : "^2.1.0"
}
}
```
```yaml
# pnpm-workspace.yaml
packages :
- apps/*
- packages/*
- themes/*
```
```json
// tsconfig.base.json
{
"compilerOptions" : {
"target" : "ES2022" , "module" : "NodeNext" , "moduleResolution" : "NodeNext" ,
"strict" : true , "noUncheckedIndexedAccess" : true , "exactOptionalPropertyTypes" : true ,
"skipLibCheck" : true , "declaration" : true , "sourceMap" : true
}
}
```
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig ({
test : {
include : [ 'tests/**/*.test.ts' , 'packages/*/test/**/*.test.ts' , 'apps/*/test/**/*.test.ts' ],
testTimeout : 30000 ,
},
})
```
- [ ] **Step 3: 安装并运行**
Run: `pnpm install && pnpm test`
Expected: 1 passed。
- [ ] **Step 4: Commit**
```bash
git add -A && git commit -m "chore(workspace): pnpm monorepo 骨架 + vitest 冒烟"
```
---
### Task 2: PostgreSQL 16 基础设施
**Files:**
- Create: `infra/docker-compose.yml`
- Create: `.env.example`
- [ ] **Step 1: compose 文件(宿主端口 5434) **
```yaml
# infra/docker-compose.yml
services :
postgres :
image : postgres:16
container_name : sf-postgres
environment :
POSTGRES_USER : siteforge
POSTGRES_PASSWORD : siteforge_dev
POSTGRES_DB : siteforge
ports : [ "5434:5432" ]
volumes : [ sf_pgdata:/var/lib/postgresql/data]
healthcheck :
test : [ "CMD-SHELL" , "pg_isready -U siteforge" ]
interval : 5s
timeout : 3s
retries : 10
volumes :
sf_pgdata :
```
- [ ] **Step 2: .env.example**
```bash
# .env.example(复制为 .env 使用;.env 已被 gitignore)
DATABASE_URL = postgres://siteforge:siteforge_dev@localhost:5434/siteforge
```
- [ ] **Step 3: 启动验证**
Run: `docker compose -f infra/docker-compose.yml up -d && sleep 5 && docker exec sf-postgres pg_isready -U siteforge`
Expected: `accepting connections` 。
- [ ] **Step 4: Commit**
```bash
git add infra .env.example && git commit -m "feat(infra): PostgreSQL16 compose(宿主5434) "
```
---
### Task 3: contracts 包——UniversalRow 契约冻结
**Files:**
- Create: `packages/contracts/package.json` 、`packages/contracts/tsconfig.json`
- Create: `packages/contracts/src/universal-row.ts` 、`packages/contracts/src/index.ts`
- Test: `packages/contracts/test/universal-row.test.ts`
- [ ] **Step 1: 写失败测试(契约冻结:字段集与版本号)**
```ts
// packages/contracts/test/universal-row.test.ts
import { describe , it , expect } from 'vitest'
import { UNIVERSAL_ROW_VERSION , isUniversalRow , type UniversalRow } from '../src/index.js'
const valid : UniversalRow = {
sourceType : 'wp:post' , sourceId : '101' , status : 'publish' , locale : 'en' ,
sourceUrl : 'https://fixture.example/news/fixture-news-a.html' ,
slug : 'fixture-news-a' , title : 'Fixture News A' , excerpt : 'Fixture excerpt A' ,
bodyHtml : '<p>Body</p>' , publishedAt : '2026-10-05T09:00:00Z' , modifiedAt : '2026-10-06T10:00:00Z' ,
taxonomyRefs : [{ taxonomy : 'category' , sourceTermId : '31' }],
attachmentRefs : [{ role : 'featured' , sourceId : '201' }],
seo : { title : 'Fixture SEO Title A' },
knownFields : {}, extras : { custom_meta : 'x' },
rawLocation : 'wxr://item/3' ,
}
describe ( 'UniversalRow 契约' , () => {
it ( '版本号为 1(冻结,变更须升版并保留转换测试)' , () => {
expect ( UNIVERSAL_ROW_VERSION ). toBe ( 1 )
})
it ( '合法对象通过守卫' , () => {
expect ( isUniversalRow ( valid )). toBe ( true )
})
it ( '缺必填字段拒绝' , () => {
const { sourceId : _omit , ... bad } = valid
expect ( isUniversalRow ( bad )). toBe ( false )
})
})
```
- [ ] **Step 2: 运行确认失败**
Run: `pnpm test -- universal-row`
Expected: FAIL(模块不存在)。
- [ ] **Step 3: 实现(字段与 spec §7.3 一字不差)**
```ts
// packages/contracts/src/universal-row.ts
export const UNIVERSAL_ROW_VERSION = 1
export interface UniversalRow {
sourceType : string
sourceId : string
status : string
locale : string
sourceUrl? : string
slug? : string
title? : string
excerpt? : string
bodyHtml? : string
publishedAt? : string
modifiedAt? : string
taxonomyRefs : Array < { taxonomy : string ; sourceTermId : string } >
attachmentRefs : Array < { role : string ; sourceId? : string ; sourceUrl? : string } >
seo : Record < string , unknown >
knownFields : Record < string , unknown >
extras : Record < string , unknown >
rawLocation : string
}
const REQUIRED : ( keyof UniversalRow )[] = [
'sourceType' , 'sourceId' , 'status' , 'locale' ,
'taxonomyRefs' , 'attachmentRefs' , 'seo' , 'knownFields' , 'extras' , 'rawLocation' ,
]
export function isUniversalRow ( v : unknown ) : v is UniversalRow {
if ( typeof v !== 'object' || v === null ) return false
const row = v as Record < string , unknown >
return REQUIRED . every (( k ) => k in row )
&& Array . isArray ( row . taxonomyRefs ) && Array . isArray ( row . attachmentRefs )
&& row . seo !== null && typeof row . seo === 'object'
&& row . knownFields !== null && typeof row . knownFields === 'object'
&& row . extras !== null && typeof row . extras === 'object'
}
```
```ts
// packages/contracts/src/index.ts
export * from './universal-row.js'
```
`package.json` ( name `@sf/contracts` , type module, main/exports 指 dist, scripts: build/test);`tsconfig.json` extends 根 base。
- [ ] **Step 4: 运行通过后 Commit**
Run: `pnpm install && pnpm test -- universal-row` → 3 passed。
```bash
git add packages/contracts && git commit -m "feat(contracts): UniversalRow 契约 v1 冻结 + 守卫 + 测试"
```
---
### Task 4: contracts 包——Route/Render/Theme Manifest 契约
**Files:**
- Create: `packages/contracts/src/route-manifest.ts` 、`render-snapshot.ts` 、`theme-manifest.ts`
- Test: `packages/contracts/test/manifests.test.ts`
- [ ] **Step 1: 写失败测试**
```ts
// packages/contracts/test/manifests.test.ts
import { describe , it , expect } from 'vitest'
import {
ROUTE_MANIFEST_VERSION , RENDER_SNAPSHOT_VERSION , THEME_MANIFEST_VERSION ,
RENDER_SNAPSHOT_FILES , isRouteManifest , isThemeManifest ,
} from '../src/index.js'
describe ( 'manifest 契约' , () => {
it ( '三个版本号冻结为 1' , () => {
expect ([ ROUTE_MANIFEST_VERSION , RENDER_SNAPSHOT_VERSION , THEME_MANIFEST_VERSION ]). toEqual ([ 1 , 1 , 1 ])
})
it ( 'Render Snapshot 文件清单冻结(spec §9.2) ' , () => {
expect ( RENDER_SNAPSHOT_FILES ). toEqual ([
'site.json' , 'content.json' , 'taxonomies.json' , 'routes.json' , 'assets.json' , 'render-manifest.json' ,
])
})
it ( '合法 RouteManifest 通过守卫' , () => {
expect ( isRouteManifest ({
manifest_version : 1 , site_id : 's1' , build_job_id : 'b1' , origin : 'https://fixture.example' ,
generated_from_route_snapshot_hash : 'abc' ,
routes : [{ exact_key : '/' , query_policy : 'ignore' , kind : 'html' , target_file : 'index.html' , status : 200 , indexable : true }],
})). toBe ( true )
})
it ( '非法 kind 拒绝' , () => {
const m : any = { manifest_version : 1 , site_id : 's' , build_job_id : 'b' , origin : 'o' , generated_from_route_snapshot_hash : 'h' , routes : [{ exact_key : '/' , query_policy : 'ignore' , kind : 'weird' , status : 200 , indexable : true }] }
expect ( isRouteManifest ( m )). toBe ( false )
})
it ( '合法 ThemeManifest 通过守卫' , () => {
expect ( isThemeManifest ({
key : 'pilot-minimal' , version : '0.1.0' , renderContractVersion : '1' ,
supportedContentTypes : [ 'news_article' , 'product' , 'basic_page' ],
requiredSiteConfig : [ 'origin' , 'listPaths' ],
templates : [{ key : 'home' , appliesTo : 'home' }],
})). toBe ( true )
})
})
```
- [ ] **Step 2: 运行确认失败** → Run: `pnpm test -- manifests` , Expected FAIL。
- [ ] **Step 3: 实现三个契约文件**
```ts
// route-manifest.ts
export const ROUTE_MANIFEST_VERSION = 1
export type RouteKind = 'html' | 'asset' | 'redirect' | 'gone' | 'dynamic' | 'unresolved'
export type QueryPolicy = 'ignore' | 'exact' | 'preserve' | 'dynamic'
export interface RouteManifestRoute {
exact_key : string
query_policy : QueryPolicy
query_key? : string | null
kind : RouteKind
target_file? : string
status : number
location? : string
content_type? : string
indexable : boolean
}
export interface RouteManifest {
manifest_version : number
site_id : string
build_job_id : string
origin : string
generated_from_route_snapshot_hash : string
routes : RouteManifestRoute []
}
const KINDS = [ 'html' , 'asset' , 'redirect' , 'gone' , 'dynamic' , 'unresolved' ]
export function isRouteManifest ( v : unknown ) : v is RouteManifest {
const m = v as RouteManifest
return !! m && m . manifest_version === ROUTE_MANIFEST_VERSION
&& Array . isArray ( m . routes ) && m . routes . every (( r ) =>
typeof r . exact_key === 'string' && KINDS . includes ( r . kind ) && typeof r . indexable === 'boolean' )
}
```
```ts
// render-snapshot.ts
export const RENDER_SNAPSHOT_VERSION = 1
export const RENDER_SNAPSHOT_FILES = [
'site.json' , 'content.json' , 'taxonomies.json' , 'routes.json' , 'assets.json' , 'render-manifest.json' ,
] as const
export type RenderSnapshotFile = ( typeof RENDER_SNAPSHOT_FILES )[ number ]
```
```ts
// theme-manifest.ts
export const THEME_MANIFEST_VERSION = 1
export interface ThemeManifest {
key : string
version : string
renderContractVersion : string
supportedContentTypes : string []
requiredSiteConfig : string []
templates : Array < { key : string ; appliesTo : string } >
}
export function isThemeManifest ( v : unknown ) : v is ThemeManifest {
const m = v as ThemeManifest
return !! m && typeof m . key === 'string' && /^\d+\.\d+\.\d+$/ . test ( m . version ?? '' )
&& typeof m . renderContractVersion === 'string'
&& Array . isArray ( m . supportedContentTypes ) && Array . isArray ( m . requiredSiteConfig )
&& Array . isArray ( m . templates )
}
```
`index.ts` 追加 export。
- [ ] **Step 4: 通过后 Commit**
Run: `pnpm test -- manifests` → 5 passed。
```bash
git add packages/contracts && git commit -m "feat(contracts): Route/Render/Theme Manifest 契约 v1 冻结"
```
---
### Task 5: apps/api 骨架 + Drizzle 接入
**Files:**
- Create: `apps/api/package.json` 、`tsconfig.json` 、`src/app.module.ts` 、`src/db/schema.ts` (本任务仅放空聚合导出)、`src/db/client.ts`
- Test: `apps/api/test/db.connect.test.ts`
- [ ] **Step 1: 写失败测试(能连上 Task 2 的 PG)**
```ts
// apps/api/test/db.connect.test.ts
import { describe , it , expect } from 'vitest'
import { getDb } from '../src/db/client.js'
describe ( '数据库连接' , () => {
it ( '连接本项目 PG( 5434)并响应 select 1' , async () => {
const db = getDb ()
const r = await db . execute ( 'select 1 as ok' )
expect ( Number (( r . rows [ 0 ] as any ). ok )). toBe ( 1 )
})
})
```
- [ ] **Step 2: 依赖与实现**
2026-10-20 16:50:01 +08:00
`apps/api/package.json` : dependencies `@nestjs/core@^10` 、`@nestjs/common@^10` 、`reflect-metadata` 、`rxjs` 、`drizzle-orm` 、`postgres` ( postgres.js);devDeps `drizzle-kit` 、`dotenv` ; scripts: `test: vitest run` 、`db:generate: drizzle-kit generate` 、`db:migrate: drizzle-kit migrate` 、`db:test-setup` (见 Task 13)。
2026-10-20 16:45:05 +08:00
```ts
// apps/api/src/db/client.ts
import 'dotenv/config'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema.js'
2026-10-20 16:53:07 +08:00
export function getDb ( connectionString = process . env . DATABASE_URL_TEST ?? process . env . DATABASE_URL ! ) {
2026-10-20 16:45:05 +08:00
const sql = postgres ( connectionString , { max : 5 })
return drizzle ( sql , { schema })
}
```
```ts
// apps/api/src/app.module.ts
import { Module } from '@nestjs/common'
@Module ({ imports : [] })
export class AppModule {}
```
`drizzle.config.ts` ( apps/api 根):dialect postgresql、schema 指 `src/db/schema.ts` 、out 指 `drizzle` 、dbCredentials 读 `DATABASE_URL` 。
- [ ] **Step 3: 运行通过** (需 `.env` 已复制、`db:up` 已起)
Run: `pnpm --filter @sf/api test` → 1 passed。
- [ ] **Step 4: Commit**
```bash
git add apps/api && git commit -m "feat(api): NestJS standalone 骨架 + Drizzle/postgres.js 接入"
```
---
### Task 6: 迁移 0001——站点域
**Files:**
- Modify: `apps/api/src/db/schema.ts`
- Create: `apps/api/drizzle/0001_sites.sql` (由 drizzle-kit generate 产出)
- Test: `tests/integration/db.sites.test.ts`
- [ ] **Step 1: 写失败测试**
```ts
// tests/integration/db.sites.test.ts(公共夹具见 Task 13 的 migrateTestHelper;本任务先内联简易版,Task 13 收敛)
import { describe , it , expect , beforeAll } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js'
describe ( '站点域约束' , () => {
it ( 'sites.key 唯一;site_config_revisions (site_id,version) 唯一' , async () => {
const db = getDb ()
await db . execute ( `insert into sites (id, key, name, default_locale, production_origin)
values (gen_random_uuid(), 'fixture', 'Fixture', 'en', 'https://fixture.example')` )
await expect ( db . execute ( `insert into sites (id, key, name, default_locale, production_origin)
values (gen_random_uuid(), 'fixture', 'Dup', 'en', 'https://x.example')` )). rejects . toThrow ()
})
})
```
- [ ] **Step 2: schema 定义(drizzle pgTable;列与 spec §5.1 一致)**
```ts
// schema.ts 追加(节选关键约束,完整列照 spec)
export const sites = pgTable ( 'sites' , {
id : uuid ( 'id' ). primaryKey (). defaultRandom (),
key : text ( 'key' ). notNull (). unique (),
name : text ( 'name' ). notNull (),
defaultLocale : text ( 'default_locale' ). notNull (),
productionOrigin : text ( 'production_origin' ). notNull (),
createdAt : timestamp ( 'created_at' , { withTimezone : true }). notNull (). defaultNow (),
updatedAt : timestamp ( 'updated_at' , { withTimezone : true }). notNull (). defaultNow (),
})
export const siteConfigRevisions = pgTable ( 'site_config_revisions' , {
id : uuid ( 'id' ). primaryKey (). defaultRandom (),
siteId : uuid ( 'site_id' ). notNull (). references (() => sites . id ),
version : integer ( 'version' ). notNull (),
data : jsonb ( 'data' ). notNull (),
checksum : text ( 'checksum' ). notNull (),
createdAt : timestamp ( 'created_at' , { withTimezone : true }). notNull (). defaultNow (),
}, ( t ) => [ uniqueIndex ( 'site_config_rev_uq' ). on ( t . siteId , t . version )])
```
- [ ] **Step 3: 生成并执行迁移**
Run: `pnpm --filter @sf/api db:generate && pnpm --filter @sf/api db:migrate`
检查 `drizzle/0001_*.sql` 内容与约束一致。
- [ ] **Step 4: 测试通过后 Commit**
```bash
git add apps/api && git commit -m "feat(db): 0001 站点域——sites/site_config_revisions + 唯一约束"
```
---
### Task 7: 迁移 0002——来源域(快照不可变)
**Files:**
- Modify: `apps/api/src/db/schema.ts`
- Test: `tests/integration/db.source.test.ts`
- [ ] **Step 1: 失败测试要点** :① `source_snapshots` UPDATE 抛错(触发器);② `UNIQUE(site_id, kind, sha256)` 重复插入抛错;③ `source_records` 稳定身份键 `UNIQUE(site_id, source_connection_id, source_type, source_id, locale, source_snapshot_id)` 生效;④ `import_jobs.idempotency_key` 唯一。
- [ ] **Step 2: schema** : `source_connections` 、`source_snapshots` ( kind enum: wxr/sitemap/media_bundle/manual_url_list; **附触发器 SQL,手写在迁移文件**——drizzle-kit 生成后追加):
```sql
CREATE OR REPLACE FUNCTION sf_forbid_update () RETURNS trigger AS $$
BEGIN RAISE EXCEPTION 'immutable table %: UPDATE forbidden' , TG_TABLE_NAME ; END $$ LANGUAGE plpgsql ;
CREATE TRIGGER source_snapshots_immutable BEFORE UPDATE OR DELETE ON source_snapshots
FOR EACH ROW EXECUTE FUNCTION sf_forbid_update ();
```
(触发器写法对四张不可变表复用:`source_snapshots` 、`content_revisions` 、`audit_logs` 、`build_input_snapshots` 。)
- [ ] **Step 3: generate + migrate + 测试通过** → **Step 4: Commit** `feat(db): 0002 来源域——connection/snapshot/job/record + 快照不可变触发器`
---
### Task 8: 迁移 0003——内容域(修订不可变)
**Files:**
- Modify: `apps/api/src/db/schema.ts`
- Test: `tests/integration/db.content.test.ts`
- [ ] **Step 1: 失败测试要点** :① `content_revisions` UPDATE 与 DELETE 均抛错;② `entry_locales` `UNIQUE(site_id, content_entry_id, locale)` ;③ 双指针列存在(current_revision_id/published_revision_id,无外键强约束时记录原因)。
- [ ] **Step 2: schema** : `content_entries` ( lifecycle_status enum active/archived)、`entry_locales` 、`content_revisions` ( source_kind enum import/manual/restore、overridden_fields jsonb default '[]'、import_job_id 可空 FK)。迁移文件追加 `content_revisions_immutable` 触发器(复用 Task 7 函数)。
- [ ] **Step 3: generate + migrate + 测试** → **Step 4: Commit** `feat(db): 0003 内容域——entry/locale/revision + 修订不可变`
---
### Task 9: 迁移 0004——分类域
**Files:** Modify `schema.ts` ; Test `tests/integration/db.taxonomy.test.ts`
- [ ] **Step 1: 失败测试要点** : `UNIQUE(site_id, key, object_type)` ( taxonomies)、`UNIQUE(site_id, taxonomy_id, source_id)` ( terms)、`content_terms` 复合主键 (site_id, content_entry_id, term_id) 且 is_primary 列存在。
- [ ] **Step 2: schema** :三表照 spec §5.4。
- [ ] **Step 3: generate + migrate + 测试** → **Step 4: Commit** `feat(db): 0004 分类域——taxonomy/term/content_terms`
---
### Task 10: 迁移 0005——媒体域
**Files:** Modify `schema.ts` ; Test `tests/integration/db.media.test.ts`
- [ ] **Step 1: 失败测试要点** : `UNIQUE(site_id, source_url)` 与 `UNIQUE(site_id, public_path)` ( media_assets);`media_usages` 主键 (media_asset_id, content_revision_id, field_path); status enum 含 ready/missing/quarantined/unsupported。
- [ ] **Step 2: schema** :照 spec §5.5。
2026-10-20 16:50:01 +08:00
- [ ] **Step 3: generate + migrate + 测试** → **Step 4: Commit** `feat(db): 0005 媒体域——assets/usages 双唯一`
2026-10-20 16:45:05 +08:00
---
### Task 11: 迁移 0006——路由注册表
**Files:** Modify `schema.ts` ; Test `tests/integration/db.routes.test.ts`
- [ ] **Step 1: 失败测试要点** :
- 纯路径唯一:两条 `(site, host, '/About')` 与 `/about` **均可插入** (exact key 区分大小写语义);
- 同 exact key 第二条插入抛错——**用 `NULLS NOT DISTINCT` **:
```sql
CREATE UNIQUE INDEX route_exact_uq ON route_registry ( site_id , host , exact_route_key , query_key ) NULLS NOT DISTINCT ;
```
- route_kind CHECK 限定六值;query_policy CHECK 限定四值。
- [ ] **Step 2: schema** :照 spec §5.6 全列;唯一索引用手写 SQL 追加进迁移文件(drizzle-kit 不生成 NULLS NOT DISTINCT 时)。
- [ ] **Step 3: generate + migrate + 测试** → **Step 4: Commit** `feat(db): 0006 路由注册表——exact/collision 双键 + NULLS NOT DISTINCT 唯一`
---
### Task 12: 迁移 0007——构建/验收/审计域
**Files:** Modify `schema.ts` ; Test `tests/integration/db.jobs.test.ts`
2026-10-20 16:50:01 +08:00
- [ ] **Step 1: 失败测试要点** : `build_jobs.idempotency_key` 唯一、状态 enum 八值(queued/snapshotting/building/validating/succeeded/failed/cancelled/stale);`build_input_snapshots` 不可变触发器 + `build_job_id` UNIQUE; `audit_logs` UPDATE/DELETE 抛错(追加式);`validation_runs/validation_issues` 表存在且 issues 有 severity 列。
2026-10-20 16:45:05 +08:00
- [ ] **Step 2: schema** :照 spec §5.7/§5.8; `audit_logs_immutable` 触发器。
- [ ] **Step 3: generate + migrate + 测试** → **Step 4: Commit** `feat(db): 0007 构建/验收/审计域 + 追加式审计`
---
### Task 13: 空库一键迁移测试(阶段 1 验收)
**Files:**
- Create: `scripts/migrate-test.sh` 与 `tests/integration/db.full-migrate.test.ts` (重构公共夹具 `tests/integration/helpers.ts` :建临时库 sf_test_<ts> → 迁移 → 传连接串给测试 → 结束 drop)
- [ ] **Step 1: 失败测试**
```ts
// tests/integration/db.full-migrate.test.ts
import { describe , it , expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js'
const EXPECTED_TABLES = [
'sites' , 'site_config_revisions' ,
'source_connections' , 'source_snapshots' , 'import_jobs' , 'source_records' ,
'content_entries' , 'entry_locales' , 'content_revisions' ,
'taxonomies' , 'terms' , 'content_terms' ,
'media_assets' , 'media_usages' ,
'route_registry' ,
'build_jobs' , 'build_input_snapshots' , 'build_artifacts' , 'validation_runs' , 'validation_issues' ,
'audit_logs' ,
]
describe ( '空库全量迁移' , () => {
it ( '一键迁移后 21 张核心表全部存在' , async () => {
const db = getDb ()
const r = await db . execute ( `select table_name from information_schema.tables where table_schema='public'` )
const tables = ( r . rows as any []). map (( x ) => x . table_name )
for ( const t of EXPECTED_TABLES ) expect ( tables ). toContain ( t )
})
})
```
2026-10-20 16:50:01 +08:00
- [ ] **Step 2: helpers 收敛** (机制:vitest `globalSetup` 创建临时库 `sf_test_<timestamp>` → 对其运行全部迁移 → 把连接串写入注入的 `process.env.DATABASE_URL_TEST` 供测试读取;`globalTeardown` drop 库。Task 6–12 的内联建库逻辑统一收敛为调用此 helper)。
2026-10-20 16:45:05 +08:00
- [ ] **Step 3: 运行** : `pnpm db:up && pnpm test` 全绿。
- [ ] **Step 4: Commit** `test(db): 空库一键迁移 + 21 表断言(阶段1验收)`
---
### Task 14: 根脚本、lint、CI 与收尾
**Files:**
- Modify: `package.json` ( scripts 补 `db:up` /`db:migrate` /`db:test` )
- Create: `.github/workflows/ci.yml` (自建 git 服务可能不跑 Actions——文件保留备用,本地以 pnpm 脚本为准)
- Create: `README.md` (快速开始:corepack → install → db:up → migrate → test)
- Create: `docs/adr/ADR-001-ORM选择Drizzle.md` (一句话决策 + 备选 Prisma 否决理由:SQL 可控、cms 实战);ADR-002~013 按 spec §3 表逐条落成短文件(每个 3–5 行)
- [ ] **Step 1: 补脚本与 README**
- [ ] **Step 2: eslint flat config + `pnpm lint` 通过**
- [ ] **Step 3: 全量验证** : `pnpm lint && pnpm typecheck && pnpm test` 全绿
- [ ] **Step 4: Commit 并推送**
```bash
git add -A && git commit -m "chore: 根脚本/lint/CI/ADR 与 README(阶段1收尾)"
git push origin main
```
---
## 验收(对照 spec §15 阶段 0/1)
2026-10-20 17:23:28 +08:00
- [ ] 原项目未被本项目修改(cms 是活跃仓库、并行会话在继续开发——Task 0.1 审查已确认;验收口径 = **本项目从未向 cms 执行任何写操作** (流程纪律),阶段验收时记录当时所见 cms HEAD 作信息性参考;**发现任何 cms 异常只报告用户,绝不 restore/checkout**)
2026-10-20 16:45:05 +08:00
- [ ] 备份有哈希与位置记录;`git status` 不出现真实试点数据
- [ ] 空环境一条命令启动数据库并迁移(Task 13)
- [ ] 所有核心约束有自动测试(不可变×4、唯一键×7+、枚举 CHECK)
- [ ] UniversalRow / Route / Render / Theme 契约冻结测试通过
- [ ] 阶段 1 完成后:基于本计划经验,撰写阶段 2(WXR 适配器)计划