diff --git a/README.md b/README.md new file mode 100644 index 0000000..402526c --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# SiteForge + +WordPress 站点到静态站点迁移引擎:读取 WXR/Sitemap/媒体归档 → 清洗入不可变内容库 → 主题化构建为自包含静态站点(Astro),全程可审计、可复现、契约冻结。 + +## 快速开始 + +前置:Node.js >= 22、Docker、[corepack](https://nodejs.org/api/corepack.html)。 + +```bash +corepack enable # 激活 pnpm(或自行安装 pnpm@10) +pnpm install # 安装依赖 +pnpm db:up # 启动 PostgreSQL 16(宿主端口 5434,等 healthcheck 就绪) +pnpm db:migrate # 开发库全量迁移(apps/api/drizzle) +pnpm db:test # 一键验收:起库 + 跑全部测试(测试用临时库,开发库零写入) +``` + +日常质量门(等价于 CI): + +```bash +pnpm lint && pnpm typecheck && pnpm test +``` + +说明:集成测试由 vitest `globalSetup` 自动创建临时库 `sf_test_` 并注入 `DATABASE_URL_TEST`,结束即 DROP——勿在 `.env` 手动设置该变量。 + +## 目录结构 + +``` +apps/api/ NestJS standalone 骨架 + Drizzle schema/迁移(21 表,数据库约束即规格) +packages/contracts/ UniversalRow / Route / Render / Theme 跨源契约(冻结,变更须升版) +tests/ 单元 + 集成测试(fixtures:WXR/Sitemap/媒体样例) +infra/ docker-compose:PostgreSQL 16(端口 5434,避开 cms 项目 5433) +scripts/ migrate-test.sh 等运维脚本 +docs/ adr/ 决策记录 · evidence/ 基线证据 · superpowers/ 规格与计划 +``` + +## 文档导航 + +- 设计基线(表结构/技术决策/阶段划分):`docs/superpowers/specs/2026-10-20-siteforge-vertical-slice-design-v2.md` +- 阶段 0+1 实施计划:`docs/superpowers/plans/2026-10-20-slice-foundation-phase0-1.md` +- 架构决策记录(ADR-001~015):`docs/adr/` +- 基线证据(环境/真实 WXR):`docs/evidence/` + +## 当前状态 + +- **阶段 1 已完成**:契约冻结、21 表数据模型全量落地(不可变触发器/唯一约束/CHECK 均落数据库并被 75 项测试锁定)、空环境一键启动+迁移验收通过。 +- **下一步:阶段 2**——WXR 适配器(流式 SAX 解析 → UniversalRow),计划基于阶段 1 经验另立。 diff --git a/docs/adr/ADR-001-ORM选择Drizzle.md b/docs/adr/ADR-001-ORM选择Drizzle.md new file mode 100644 index 0000000..6061ad5 --- /dev/null +++ b/docs/adr/ADR-001-ORM选择Drizzle.md @@ -0,0 +1,6 @@ +# ADR-001:ORM 选择 Drizzle(+ PostgreSQL 16) + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:ORM 用 Drizzle,数据库 PostgreSQL 16;所有 Schema 通过迁移文件管理,关键约束(不可变触发器、NULLS NOT DISTINCT 唯一、CHECK)直接落数据库。 +- **理由**:SQL 完全可控——库级 DDL(触发器/部分唯一索引)在手写迁移里可精确表达;cms 项目实战已验证该组合。备选 Prisma 被否决:复杂约束的表达要绕开生成器,失控感强。 +- **权衡**:无成熟 migration 可视化工具,靠 drizzle-kit + 快照;换来的是约束即规格、无 ORM 魔法。 diff --git a/docs/adr/ADR-002-服务形态NestJS单体.md b/docs/adr/ADR-002-服务形态NestJS单体.md new file mode 100644 index 0000000..119a62d --- /dev/null +++ b/docs/adr/ADR-002-服务形态NestJS单体.md @@ -0,0 +1,6 @@ +# ADR-002:服务形态 = NestJS 模块化单体 + Standalone App Context + CLI + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:NestJS 模块化单体,本切片以 Standalone App Context 供 CLI 使用,不监听公网端口;服务层未来由 HTTP Controller 复用。 +- **理由**:导入/构建管道是长任务,先以 CLI 形态交付价值;NestJS 的模块/DI 体系保证后续加 HTTP 入口时服务层零改动。 +- **权衡**:起步比裸脚本重(装饰器/依赖注入样板);换来明确的模块边界与可测性。 diff --git a/docs/adr/ADR-003-Job进程内Runner.md b/docs/adr/ADR-003-Job进程内Runner.md new file mode 100644 index 0000000..205aab6 --- /dev/null +++ b/docs/adr/ADR-003-Job进程内Runner.md @@ -0,0 +1,6 @@ +# ADR-003:Job 执行 = 进程内 Runner + 数据库 Job 状态 + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:导入/构建 Job 由进程内 Runner 执行,状态持久化在数据库(import_jobs/build_jobs 表),不引入 BullMQ 等外部队列。 +- **理由**:单机单进程已满足切片吞吐;状态落库使进程中断后可识别 stale Job 并恢复/标记失败,不依赖外部组件。 +- **权衡**:无分布式并行能力;若未来需要横向扩展须引入真正的队列——但 Job 表结构已按状态机设计,迁移成本低。 diff --git a/docs/adr/ADR-004-来源连接与快照分离.md b/docs/adr/ADR-004-来源连接与快照分离.md new file mode 100644 index 0000000..7bac5ed --- /dev/null +++ b/docs/adr/ADR-004-来源连接与快照分离.md @@ -0,0 +1,6 @@ +# ADR-004:原始输入 = Source Connection 与 Source Snapshot 分离 + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:来源连接(source_connections)与输入快照(source_snapshots)分为两张表;每个 WXR/Sitemap/已解包媒体目录形成一个不可变 Snapshot 并记录 Hash。 +- **理由**:连接是可变的凭据/位置信息,快照是不可变的证据基线——后续所有解析、清洗、审计都锚定快照哈希,可追溯可复现。 +- **权衡**:每次导入多一次快照落库;换取原文永远可回查(HTML 清洗差异报告、导入争议仲裁的依据)。 diff --git a/docs/adr/ADR-005-WXR流式SAX解析.md b/docs/adr/ADR-005-WXR流式SAX解析.md new file mode 100644 index 0000000..61ac702 --- /dev/null +++ b/docs/adr/ADR-005-WXR流式SAX解析.md @@ -0,0 +1,6 @@ +# ADR-005:WXR 解析 = 流式 SAX 解析 + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:WXR 用流式 SAX 解析;禁止整文件正则解析;限制字节、节点、深度、字段长度和处理时间。 +- **理由**:真实 WXR 可达数百 MB,整文件 DOM/正则会内存爆炸或静默错切(CDATA、实体、命名空间变体);流式 + 硬限制保证对畸形输入可预测地失败。 +- **权衡**:实现比正则贵得多、状态机复杂;换取对任意大小/畸形文件的健壮性与确定性资源上界。 diff --git a/docs/adr/ADR-006-内容身份稳定键.md b/docs/adr/ADR-006-内容身份稳定键.md new file mode 100644 index 0000000..9ae2bc6 --- /dev/null +++ b/docs/adr/ADR-006-内容身份稳定键.md @@ -0,0 +1,6 @@ +# ADR-006:内容身份 = Source Record 稳定键 + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:WordPress 导入内容的身份锚定 source_records 的稳定键(来源类型 + WordPress ID);Permalink 只作为 URL 证据保存,不作为稳定主键。 +- **理由**:WP 的 permalink 可随站点设置/ slug 修改而变,重导入时按链接匹配合错内容;post ID 在单个来源内天然唯一且不可变。 +- **权衡**:跨来源同名 ID 须靠 source 维度区分(复合身份);换取重导幂等与身份稳定。 diff --git a/docs/adr/ADR-007-路由键与碰撞键分离.md b/docs/adr/ADR-007-路由键与碰撞键分离.md new file mode 100644 index 0000000..b9386d4 --- /dev/null +++ b/docs/adr/ADR-007-路由键与碰撞键分离.md @@ -0,0 +1,6 @@ +# ADR-007:URL 模型 = Exact Route Key 与 Collision Key 分离 + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:路由注册表存 Exact Route Key(原文、不大小写折叠、不去尾斜杠)与 Collision Key(折叠+规范化,仅用于风险检查)两个独立键。 +- **理由**:实际路由必须按静态站点服务器的真实行为匹配(大小写/尾斜杠敏感);而大小写碰撞、尾斜杠歧义是迁移质量风险,需要单独检测报警而非混入路由匹配。 +- **权衡**:route_registry 多一列维护;换取"路由正确性"与"碰撞风险提示"互不污染。 diff --git a/docs/adr/ADR-008-媒体内容寻址存储.md b/docs/adr/ADR-008-媒体内容寻址存储.md new file mode 100644 index 0000000..6dc24ee --- /dev/null +++ b/docs/adr/ADR-008-媒体内容寻址存储.md @@ -0,0 +1,6 @@ +# ADR-008:媒体 = 本地 Content-Addressed Storage + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:媒体文件存本地 content-addressed 存储(按内容哈希寻址);本切片不用 MinIO/S3;未来对象存储通过更换 Storage Adapter 接入。 +- **理由**:哈希寻址天然去重且完整性可校验;本地 FS 起步零运维。cms 现状评估(docs/evidence)也表明对象存储在现阶段是非必要复杂度。 +- **权衡**:单机存储无冗余、迁移到对象存储需搬运数据;接口已按 Adapter 抽象,替换是受控变更。 diff --git a/docs/adr/ADR-009-HTML白名单清洗.md b/docs/adr/ADR-009-HTML白名单清洗.md new file mode 100644 index 0000000..f924d48 --- /dev/null +++ b/docs/adr/ADR-009-HTML白名单清洗.md @@ -0,0 +1,6 @@ +# ADR-009:HTML 安全 = 白名单清洗 + 原文保留在 Source Snapshot + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:WordPress 内容按白名单清洗后才进入 content_revisions;原始 HTML 永远保留在 Source Snapshot;清洗差异写入报告。 +- **理由**:WP 内容可能携带脚本/内联样式/可疑 iframe,白名单是唯一安全默认;原文保留使清洗决策可审计、可回溯、可重跑(白名单升级后可重新清洗)。 +- **权衡**:存储翻倍(原文+清洗后);换取安全边界清晰且清洗永不丢信息。 diff --git a/docs/adr/ADR-010-发布双指针.md b/docs/adr/ADR-010-发布双指针.md new file mode 100644 index 0000000..5ace9ef --- /dev/null +++ b/docs/adr/ADR-010-发布双指针.md @@ -0,0 +1,6 @@ +# ADR-010:发布模型 = current/published 双指针 + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:content_entries 维护 current(编辑态)与 published(线上态)双指针;导入只更新 current,发布只移动 published 指针,从不修改 Revision 内容。 +- **理由**:导入是批量实验性操作,绝不能直接改线上内容;Revision 不可变 + 指针移动让"发布了什么""何时回滚"完全可追溯。 +- **权衡**:查询线上内容须 join 指针而非直取最新;换取导入安全与发布原子性。 diff --git a/docs/adr/ADR-011-冻结RenderSnapshot.md b/docs/adr/ADR-011-冻结RenderSnapshot.md new file mode 100644 index 0000000..3a38154 --- /dev/null +++ b/docs/adr/ADR-011-冻结RenderSnapshot.md @@ -0,0 +1,6 @@ +# ADR-011:构建 = 冻结 Render Snapshot + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:Astro 构建输入是冻结的 Render Snapshot(build_input_snapshots),禁止查询数据库、禁止在线拉内容、禁止 Seed fallback。 +- **理由**:构建可复现是静态站点的核心承诺——同一次 Build 产物必须确定性一致;禁止运行时数据访问从机制上杜绝"构建结果依赖当时的库状态"。 +- **权衡**:构建前多一步快照物化;换来 Build 可重放、可缓存、可审计。 diff --git a/docs/adr/ADR-012-最小ThemeManifest.md b/docs/adr/ADR-012-最小ThemeManifest.md new file mode 100644 index 0000000..3e32537 --- /dev/null +++ b/docs/adr/ADR-012-最小ThemeManifest.md @@ -0,0 +1,6 @@ +# ADR-012:主题 = 最小 Theme Manifest + Render Contract + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:主题由最小 Theme Manifest(入口/资产/能力声明)+ Render Contract(UniversalRow → 页面)定义;完整 Block 协议后置到后续阶段。 +- **理由**:先冻结渲染契约让主题与管道并行开发;第一主题不得依赖站点专属数据导入代码,保证主题可移植。 +- **权衡**:首版表现力有限(非全功能 Block 系统);换取契约早冻结、主题生态不被早期实现绑死。 diff --git a/docs/adr/ADR-013-预览Artifact自包含.md b/docs/adr/ADR-013-预览Artifact自包含.md new file mode 100644 index 0000000..3a2dfdc --- /dev/null +++ b/docs/adr/ADR-013-预览Artifact自包含.md @@ -0,0 +1,6 @@ +# ADR-013:预览 = Artifact 自包含 + +- **状态**:已采纳(spec §3,阶段 0) +- **决策**:预览服务读取与生产构建同一 Build 的 Route Manifest 与 Artifact,不读取实时数据库。 +- **理由**:预览必须所见即所得——与线上完全同源的产物;读实时库会让预览与实际发布内容出现不可解释的差异。 +- **权衡**:内容更新须走完整 Build 才能预览(无实时草稿预览);换取预览的绝对可信。 diff --git a/docs/adr/ADR-014-测试文件串行执行.md b/docs/adr/ADR-014-测试文件串行执行.md new file mode 100644 index 0000000..3c1caf3 --- /dev/null +++ b/docs/adr/ADR-014-测试文件串行执行.md @@ -0,0 +1,6 @@ +# ADR-014:vitest fileParallelism = false(集成测试文件串行) + +- **状态**:已采纳(Task 10 实施发现,阶段 1) +- **决策**:vitest 配置 `fileParallelism: false`,测试文件串行执行。 +- **理由**:集成测试共库,清场会做库级 DDL(不可变表 `DISABLE/ENABLE TRIGGER` 对**所有会话**可见)。文件级并行时 A 文件清场窗口会与 B 文件的不可变断言竞态——实测 db.media 清场 disable content_revisions_immutable 期间,db.content 恰好断言该触发器,UPDATE 意外放行。串行执行彻底消除该竞态。 +- **权衡**:套件失去文件级并行;但全套件仅数秒,并行无实际收益,确定性优先。 diff --git a/docs/adr/ADR-015-globalSetup临时库.md b/docs/adr/ADR-015-globalSetup临时库.md new file mode 100644 index 0000000..9200ac8 --- /dev/null +++ b/docs/adr/ADR-015-globalSetup临时库.md @@ -0,0 +1,6 @@ +# ADR-015:globalSetup 临时测试库 + vitest 2 具名导出陷阱 + +- **状态**:已采纳(Task 13 实施发现,阶段 1) +- **决策**:集成测试库由 vitest globalSetup 供给:setup 用开发库凭据连维护库 postgres,`CREATE DATABASE sf_test_` → 程序化跑全量迁移 → 写 `process.env.DATABASE_URL_TEST`;teardown `DROP DATABASE ... WITH (FORCE)`。彻底消除"测试碰开发库"与"清场残留"两类问题。 +- **理由**:每次运行拿到完好空库且全量迁移被真实验收;开发库零写入,无需预迁移。 +- **权衡/陷阱**:① vitest 2 的 globalSetup 加载器在有 default 导出时只取 default 作 setup、**不捕获具名 teardown**——必须用具名 `setup`/`teardown` 双导出且不写 default,teardown 才会注册执行。② worker 在 setup 之后派生,env 注入才对测试生效。