docs(plan): Plan 1 实施计划——阶段0+1(基线备份/工程骨架/21表契约冻结)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-20 16:45:05 +08:00
parent 80dcc8378f
commit e6c771151d
@@ -0,0 +1,851 @@
# 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**。
3. **真实数据不入 git**:`.gitignore` 已忽略 `storage/`、`artifacts/`、`.env*`。夹具必须脱敏手写,不从真实 WXR 复制内容。
4. **Windows 环境**:路径分隔符、长路径(媒体全角文件名)注意;命令以 Git Bash 为准。
5. 上位规格字段定义见 spec §5(表结构原文)、§7.3(UniversalRow)、§12.1(Route Manifest)、§9.1(Theme Manifest)——本计划代码与之一致,冲突时以 spec 为准并回报。
---
### Task 0.1: 环境版本基线记录
**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 停止并报告用户。**
- [ ] **Step 2: 写基线文档**
```markdown
# docs/evidence/env-baseline.md
- 日期:(填)
- Git commit:(git rev-parse --short HEAD 输出)
- node:(版本)
- pnpm:(版本)
- docker / compose:(版本)
- PostgreSQL 镜像:postgres:16(固定 tag,不用 latest)
```
- [ ] **Step 3: Commit**
```bash
git add docs/evidence/env-baseline.md
git commit -m "chore(evidence): 记录阶段0环境版本基线"
```
---
### Task 0.2: cms 数据备份(第二存储)⚠️ 需用户确认
**Files:** 无代码;产出备份与 `docs/evidence/backup-record.md`
- [ ] **Step 1: 向用户确认备份目标路径 BACKUP_DIR**(如 `E:\backups\cms-2026-10-20`)。未确认前跳过本任务后续步骤。
- [ ] **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 数据备份记录"
```
---
### Task 0.3: 脱敏最小夹具
**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>
<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:7:"SpecsTab";s:7:"content";s:12:"<p>Tab</p>";}}]]></wp:meta_value></wp:postmeta>
</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/媒体夹具(全虚构)"
```
---
### Task 0.4: 真实 WXR 输入基数记录(只读 cms)
**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: 依赖与实现**
`apps/api/package.json`:dependencies `@nestjs/core@^10`、`@nestjs/common@^10`、`reflect-metadata`、`rxjs`、`drizzle-orm`、`postgres`(postgres.js);devDeps `drizzle-kit`、`dotenv`;scripts:`db:generate: drizzle-kit generate`、`db:migrate: drizzle-kit migrate`、`db:test-setup`(见 Task 13)。
```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'
export function getDb(connectionString = process.env.DATABASE_URL!) {
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。
- ] **Step 3: generate + migrate + 测试** → **Step 4: Commit** `feat(db): 0005 媒体域——assets/usages 双唯一`
---
### 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`
- [ ] **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 列。
- [ ] **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)
})
})
```
- [ ] **Step 2: helpers 收敛**(Task 6–12 的内联建库逻辑统一为:每个集成测试文件 beforeAll 用 helper 建干净库;此处抽出公共函数)。
- [ ] **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)
- [ ] 原项目未被修改(`git -C D:/project/cms status` 干净)
- [ ] 备份有哈希与位置记录;`git status` 不出现真实试点数据
- [ ] 空环境一条命令启动数据库并迁移(Task 13)
- [ ] 所有核心约束有自动测试(不可变×4、唯一键×7+、枚举 CHECK)
- [ ] UniversalRow / Route / Render / Theme 契约冻结测试通过
- [ ] 阶段 1 完成后:基于本计划经验,撰写阶段 2(WXR 适配器)计划