Files
cms-website/docs/superpowers/plans/2026-10-20-slice-foundation-phase0-1.md
T

852 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 适配器)计划