Files
cms-website/docs/superpowers/plans/2026-10-20-slice-foundation-phase0-1.md
T
2026-10-20 17:34:28 +08:00

866 lines
35 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*`(已加 `!.env.example` 反排除,示例文件可提交)。夹具必须脱敏手写,不从真实 WXR 复制内容。
4. **Windows 环境**:路径分隔符、长路径(媒体全角文件名)注意;命令以 Git Bash 为准。
5. 上位规格字段定义见 spec §5(表结构原文)、§7.3(UniversalRow)、§12.1(Route Manifest)、§9.1(Theme Manifest)——本计划代码与之一致,冲突时以 spec 为准并回报。
---
### Task 0.1: 环境版本基线记录 ✅ 完成(commit d64a035,规格审查通过)
> 编排注(2026-10-20):审查发现 cms 为**活跃仓库**(并行会话在继续提交),其基线降级为信息性记录;本项目不变量 = 绝不向 cms 写入(纪律),而非 cms 状态不变。
**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: 记录 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: 写基线文档**
```markdown
# docs/evidence/env-baseline.md
- 日期:(填)
- Git commit:(git rev-parse --short HEAD 输出)
- node:(版本)
- pnpm:(版本)
- docker / compose:(版本)
- PostgreSQL 镜像:postgres:16(固定 tag,不用 latest)
```
- [ ] **Step 5: Commit**
```bash
git add docs/evidence/env-baseline.md
git commit -m "chore(evidence): 记录阶段0环境版本基线(含 cms 只读基线与 gitignore 验证)"
```
(注:Step 2/3 的记录输出并入 Step 4 的文档一并提交。)
---
### Task 0.2: cms 数据备份(第二存储)⏸ **用户暂缓(2026-10-20)——待备份目标盘就绪后补做,不阻塞后续任务**
**Files:** 无代码;产出备份与 `docs/evidence/backup-record.md`
- [ ] **Step 1: 向用户确认备份目标路径 BACKUP_DIR**(如 `E:\backups\cms-2026-10-20`),**并同时确认试点数据的使用授权与敏感信息范围**(授权结论记入 backup-record.md)。未确认前跳过本任务后续步骤。
- [ ] **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: 脱敏最小夹具 ✅ 完成(commit 681163b + 修正 ee58 待查,规格审查通过;审查发现并修复计划中 PHP 序列化长度前缀笔误 s:7→s:8、s:12→s:10;203/204 悬空图库引用为有意设计,已记入夹具 README)
**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:8:"SpecsTab";s:7:"content";s:10:"<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)✅ 完成(commit 2ee8485,规格审查通过——台账四类全部吻合;75 条台账外类型已记录,留阶段 6 范围决策)
**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:`test: vitest run`、`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_TEST ?? 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 收敛**(机制:vitest `globalSetup` 创建临时库 `sf_test_<timestamp>` → 对其运行全部迁移 → 把连接串写入注入的 `process.env.DATABASE_URL_TEST` 供测试读取;`globalTeardown` drop 库。Task 6–12 的内联建库逻辑统一收敛为调用此 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)
- [ ] 原项目未被本项目修改(cms 是活跃仓库、并行会话在继续开发——Task 0.1 审查已确认;验收口径 = **本项目从未向 cms 执行任何写操作**(流程纪律),阶段验收时记录当时所见 cms HEAD 作信息性参考;**发现任何 cms 异常只报告用户,绝不 restore/checkout**)
- [ ] 备份有哈希与位置记录;`git status` 不出现真实试点数据
- [ ] 空环境一条命令启动数据库并迁移(Task 13)
- [ ] 所有核心约束有自动测试(不可变×4、唯一键×7+、枚举 CHECK)
- [ ] UniversalRow / Route / Render / Theme 契约冻结测试通过
- [ ] 阶段 1 完成后:基于本计划经验,撰写阶段 2(WXR 适配器)计划