diff --git a/docs/superpowers/plans/2026-10-20-slice-foundation-phase0-1.md b/docs/superpowers/plans/2026-10-20-slice-foundation-phase0-1.md new file mode 100644 index 0000000..76cea62 --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-slice-foundation-phase0-1.md @@ -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 `,记录名字,备份后不负责重启——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 + + + + Fixture Sitehttps://fixture.example + 1.2 + + + <![CDATA[Fixture News A]]> + https://fixture.example/news/fixture-news-a.html + Mon, 05 Oct 2026 09:00:00 +0000 + + https://fixture.example/?p=101 + Body with link and

]]>
+ + 101 + + + + + + + + + +
+ + + <![CDATA[Fixture Product]]> + https://fixture.example/products/fixture-product/ + 111 + + Product body

]]>
+ + + Tab

";}}]]>
+
+ + + <![CDATA[Fixture About]]>https://fixture.example/about-us/ + 121 + + About body

]]>
+
+ + + + + + + + + + + <![CDATA[fixture-a]]>https://fixture.example/?attachment_id=201 + 201 + https://fixture.example/wp-content/uploads/2026/10/fixture-a.png + + <![CDATA[fixture-b]]>https://fixture.example/?attachment_id=202 + 202 + https://fixture.example/wp-content/uploads/2026/10/fixture-b.png + +
+
+``` + +- [ ] **Step 2: sitemap 夹具** + +```xml + + + https://fixture.example/ + https://fixture.example/news/fixture-news-a.html + https://fixture.example/products/fixture-product/ + https://fixture.example/about-us/ + +``` + +- [ ] **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 "" "$WXR" +grep -o "" "$WXR" | sort | uniq -c +grep -c "" "$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@", + "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: '

Body

', 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 + knownFields: Record + extras: Record + 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 + 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_ → 迁移 → 传连接串给测试 → 结束 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 适配器)计划