docs(plan): Plan 1 实施计划——阶段0+1(基线备份/工程骨架/21表契约冻结)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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 适配器)计划
|
||||
Reference in New Issue
Block a user