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

35 KiB
Raw Blame History

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: 环境版本基线记录

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: 写基线文档
# docs/evidence/env-baseline.md
- 日期:(填)
- Git commit:(git rev-parse --short HEAD 输出)
- node:(版本)
- pnpm:(版本)
- docker / compose:(版本)
- PostgreSQL 镜像:postgres:16(固定 tag,不用 latest)
  • Step 5: Commit
git add docs/evidence/env-baseline.md
git commit -m "chore(evidence): 记录阶段0环境版本基线(含 cms 只读基线与 gitignore 验证)"

(注:Step 2/3 的记录输出并入 Step 4 的文档一并提交。)


Task 0.2: cms 数据备份(第二存储)⚠️ 需用户确认

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: 拷贝三块数据
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: 记录备份哈希与大小
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(只提交记录文档)
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 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 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

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 基数

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

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: 写失败测试

// 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 文件
// 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"
  }
}
# pnpm-workspace.yaml
packages:
  - apps/*
  - packages/*
  - themes/*
// tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",
    "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true,
    "skipLibCheck": true, "declaration": true, "sourceMap": true
  }
}
// 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
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)

# 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
# .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
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: 写失败测试(契约冻结:字段集与版本号)

// 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 一字不差)
// 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'
}
// 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。

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: 写失败测试

// 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: 实现三个契约文件

// 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')
}
// 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]
// 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。

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)

// 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)。

// 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 })
}
// 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
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: 写失败测试

// 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 一致)
// 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
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 生成后追加):

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:
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: 失败测试

// 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 并推送

git add -A && git commit -m "chore: 根脚本/lint/CI/ADR 与 README(阶段1收尾)"
git push origin main

验收(对照 spec §15 阶段 0/1)

  • 原项目未被修改(cms 的 git status --porcelain 输出与 Task 0.1 记录的基线逐字节一致——无新增改动即可;cms 工作树可能存在既有未提交改动,那是基线的一部分。发现差异只报告用户,绝不 restore/checkout cms)
  • 备份有哈希与位置记录;git status 不出现真实试点数据
  • 空环境一条命令启动数据库并迁移(Task 13)
  • 所有核心约束有自动测试(不可变×4、唯一键×7+、枚举 CHECK)
  • UniversalRow / Route / Render / Theme 契约冻结测试通过
  • 阶段 1 完成后:基于本计划经验,撰写阶段 2(WXR 适配器)计划