# M8 从内存态到可持续演进：为什么先收数据与契约边界

做到 `M8` 时，这个项目已经不缺“能演示”的链路了。

前面的里程碑已经把很多关键能力跑通了：

- 后台能登录，也能最小编辑草稿
- 公开站能展示真实内容
- 导出、AI、发布与角色边界都已有最小闭环

但如果把这些能力放到更长的时间线上看，会有一个非常现实的问题：

它们大多还站在“可跑 demo”的地基上，而不是“可持续演进”的地基上。

最明显的症状包括：

- 草稿 / 发布流仍然是内存态，服务一重启就丢
- `admin / web` 内部还各自维护一部分类型和请求封装
- 测试能过，但真实本地联调仍可能暴露路径和运行时问题

所以 `M8` 要解决的，不是继续加业务功能，而是把项目从“内存态 demo”推进到“数据可持续、契约可共享、真实链路可反复验证”的状态。

这篇文章就围绕 `M8` 讲清楚三件事：

1. 为什么这一轮先收数据库和持久化边界
2. 为什么共享契约要先抽最小版，而不是一口气做全量 SDK
3. 为什么真实本地冒烟和测试栈统一，也属于这一轮基础设施收束的一部分

## 一、为什么 `M8` 必须单独拉出来做

从用户视角看，`M7` 之后项目已经很像一个“能用的简历系统”了。但从工程视角看，它还存在两个明显不稳定点：

- 数据还不够可持续
- 契约还不够集中

这两个问题在教程型项目里尤其重要，因为读者接下来最自然会问：

- 服务重启后数据还在吗？
- 前后端的简历结构到底以谁为准？
- 以后继续扩内容模块，会不会又把类型和请求散回各个 app？

如果这些问题不先收住，后面无论是继续做 UI、继续做 AI，还是继续写教程，都会越来越难讲清楚。

所以 `M8` 的真正价值，不是“把数据库接进来”这么简单，而是让前面已经存在的主链路第一次站上可持续演进的基础设施。

## 二、`M8` 这轮到底要交付什么

这一轮的交付目标可以概括成一句话：

让读者能看到项目如何从内存态发布流出发，先立数据库骨架和最小表结构，再把服务平滑切到持久化实现，接着抽出最小共享契约，最后通过真实联调和测试栈统一把这一轮收稳。

这里面有五个关键边界。

### 1. 先立数据库骨架，不急着重写业务

`M8` 的第一步不是立刻去改 `ResumePublicationService`，而是先把数据库运行时、环境变量边界、CLI 入口和最小连接能力立起来。

这样做的好处是：

- 后续 issue 都站在统一数据库基础之上
- 读者能先理解“数据库边界长什么样”
- 不会一上来就把业务服务重构、表设计和 CLI 使用全堆在一起

### 2. 用“草稿一份 + 发布快照追加”收住当前模型

这一轮没有把简历拆成很多张细表，也没有做复杂版本系统。

相反，`M8` 非常克制地先收住一个当前阶段最适合的模型：

- 草稿只有一份
- 发布是追加快照
- 简历内容仍然以完整 JSON 承载

这个模型足够支撑 v1 的业务边界，也能为后续历史查询、回滚或商业版变体留口，但不会过早把教学重点拖进复杂表设计里。

### 3. 持久化替换要尽量保持 API 语义稳定

`M8` 的持久化改造，不是为了“接口焕然一新”，而是为了让已有主链路平滑过渡。

这意味着：

- 控制器和接口语义尽量不变
- 服务层从内存态换成 repository + database
- 测试重点转为“服务重启后数据是否还在”

这比一边持久化、一边重做整个 API 更适合教程型项目的节奏。

### 4. 共享契约先抽最小版，不做完整 SDK

当持久化已经成立之后，前后端契约开始变成更明显的维护风险。

但 `M8` 也没有一口气上复杂 SDK 或 OpenAPI 代码生成，而是先抽最小共享契约：

- 先抽简历主链路
- 先抽最容易漂移的类型和请求方法
- 继续保留 app 内的薄封装层

这个选择很重要，因为它既解决了重复维护问题，又没有把当前阶段的学习成本拉得过高。

### 5. 真实本地冒烟和测试栈统一也是这一轮的一部分

这一步很容易被忽视，但实际上非常关键。

因为持久化和共享契约改完之后，测试全过并不等于真实联调就一定没问题。`M8` 恰恰通过真实本地冒烟暴露了一个典型问题：

- 推表库和运行库不是同一个 sqlite 文件

而 `issue-41` 又把整个仓库的测试主线从 Jest 收束到了 Vitest。

这两件事虽然看起来像“收尾”，但它们其实都属于这一轮同一个主题：让仓库真正从 demo 状态进入可持续演进状态。

## 三、`M8` 为什么要按这些 issue 来拆

这一轮的拆法，其实是在回答一个很典型的基础设施问题：

当一个项目已经能演示主要业务能力后，应该先收哪些边界，才能让它进入更稳定的长期演进阶段？

`M8` 的答案是：

先收数据库边界，再收持久化模型，再收服务替换，再收共享契约，最后收真实联调与测试主线。

### issue-36：先立数据库边界

第一步不是业务表，而是先把 `Drizzle + libsql`、环境变量、数据库 provider 和 CLI 入口立起来。

这一层真正固定下来的，是：

- 数据库运行时如何被创建
- 本地默认数据库文件放在哪里
- 推表与运行时如何共用同一套环境边界

这一步让数据库不再只是“后面要接”，而是真正成为当前仓库的一部分。

### issue-37：再立最小持久化模型

数据库骨架有了以后，下一步才是表结构。

这一层重点不是做复杂范式，而是把当前最适合的业务模型钉清楚：

- 单份标准简历
- 草稿一份
- 发布快照追加
- `resume_json` 直接承载完整结构

这一步实际上是在保护教学重点，把“发布流”继续放在模型中心，而不是一开始就被复杂拆表夺走注意力。

### issue-38：把服务平滑切到持久化实现

表结构稳定后，才轮到真正把 `ResumePublicationService` 从内存态切到数据库。

这里最重要的不是“写一个 repository”，而是：

- 保持对外 API 语义尽量稳定
- 让草稿 / 发布数据在服务重启后仍然存在
- 用真实 sqlite 文件验证“这次真的持久化了”

这一步完成后，项目才真正从“演示数据”迈向“可持续数据”。

### issue-39：先抽最小共享契约

当持久化成立以后，`admin / web` 内部重复维护类型和请求，就会变成新的风险点。

这一步的重点不是做完整 SDK，而是非常克制地先抽：

- 简历主链路共享类型
- 最核心的请求方法
- 导出链接构造

同时保留 app 内的薄封装层，让迁移成本和教学理解成本都保持可控。

### issue-40：用真实联调收掉隐藏问题

这是 `M8` 很有价值的一步。

它证明了一件事：测试全过，不代表真实链路一定没问题。

本次真实联调暴露出 repo root 解析错误，导致推表数据库和运行时数据库不一致。这类问题如果没有真实本地冒烟，很容易长期躲在仓库里。

所以 `issue-40` 的价值，不是再做一个功能，而是让这一阶段的基础设施真正经得起现实验证。

### issue-41：把测试主线统一到 Vitest

这一轮虽然看起来像额外插入的测试基建，但其实和 `M8` 的主题是连在一起的。

因为当仓库开始走向持久化、共享契约和更稳定的联调流程后，测试栈如果还分裂，也会拖慢后续演进。

所以 `issue-41` 把 `server` 从 Jest 切到 Vitest，本质上是在把“可持续演进”的工程主线也统一起来。

## 四、这一轮最重要的设计取舍

### 1. 为什么数据库骨架先于服务重写

如果一开始就直接改业务服务，读者会同时面对：

- 环境变量边界
- 数据库 client 创建
- 表设计
- 仓储抽象
- 业务服务替换

这会让教学重点非常分散。

所以 `M8` 先把数据库骨架单独立住，本质上是在先收基础设施，再谈业务替换。

### 2. 为什么当前阶段使用 JSON 存完整简历是合理的

从范式角度看，把 `profile / experiences / projects / skills` 拆成多张表好像更“标准”。但对当前 v1 来说，真正更重要的是：

- 单份标准简历
- 草稿 / 发布边界
- 持久化主链路成立

而不是复杂查询优化。

所以 `resume_json` 直接承载完整 `StandardResume`，是一个非常合理的教学型取舍。它把模型复杂度控制在了当前最值得解释的地方。

### 3. 为什么共享契约不应该一步删光 app 内封装

很多人做共享包时，会有一种冲动：既然要抽，就把 app 内代码一口气删掉。

但 `M8` 的做法更克制：

- 抽共享类型和核心请求
- app 内保留薄封装

这样做的价值在于：

- import 路径改动更小
- 出问题时更容易回滚
- 教学上更容易讲清楚“渐进迁移”的过程

### 4. 为什么真实本地冒烟仍然不可替代

`M8` 的真实问题就说明了这一点：

- 单测过了
- E2E 过了
- `db:push` 也过了

但运行时还是会报表不存在。

这是因为测试和 CLI 都没有真正覆盖到“dev server 实际用的是哪个路径”这个问题。

所以真实本地冒烟，不是和自动化测试重复，而是在补另一类非常容易被忽略的风险。

### 5. 为什么测试框架统一也属于“可持续演进”

当 monorepo 继续变大时，双测试栈会让：

- 脚本说明分裂
- CI 解释变复杂
- 依赖和写法越来越不统一

所以把 `server` 也切到 Vitest，不只是一个工具偏好，而是在让整个仓库的工程语言继续收敛。

## 五、关键文件应该怎么读

如果你想顺着代码和文档复盘 `M8`，建议优先看这几组材料。

### 数据库骨架

- `apps/server/src/database/database.config.ts`
- `apps/server/src/database/database.client.ts`
- `apps/server/src/database/database.module.ts`
- `apps/server/drizzle.config.ts`

这里解决的是“数据库边界如何进入项目”。

### 表结构与记录构造

- `apps/server/src/database/schema.ts`
- `apps/server/src/database/resume-records.ts`

这里解决的是“草稿一份 + 发布快照追加”的最小持久化模型。

### 服务持久化替换

- `apps/server/src/modules/resume/resume-publication.repository.ts`
- `apps/server/src/modules/resume/resume-publication.service.ts`

这里解决的是“内存态服务如何平滑切到数据库实现”。

### 共享契约

- `packages/api-client/src/resume.ts`
- `apps/admin/lib/resume-draft-api.ts`
- `apps/web/lib/published-resume-api.ts`

这里解决的是“共享契约如何先做最小抽离，并保留 app 内薄封装”。

### 真实联调与路径修复

- `apps/server/src/config/repo-root.ts`
- `apps/server/src/config/env-paths.ts`

这里解决的是“为什么测试都过了，真实联调仍会暴露新问题”。

### 测试主线统一

- `apps/server/vitest.config.mts`
- `apps/server/vitest.e2e.config.mts`

这里解决的是“为什么统一测试栈也是这一轮基础设施收束的一部分”。

## 六、`M8` 的测试重点是什么

`M8` 的测试重点不是新增更多业务场景，而是确认数据边界、契约边界和真实链路是否已经站稳。

### 1. 先锁数据库边界和表结构

这里至少要证明：

- 环境变量解析正确
- 本地 sqlite 文件可连通
- schema 推表可执行
- 表结构和记录构造语义稳定

### 2. 再锁持久化服务行为

这一层要证明：

- 草稿能保存
- 发布会生成快照
- 发布后草稿变化不影响已发布内容
- 服务重启后数据仍然存在

### 3. 再锁共享契约接入

这一层要证明：

- `admin / web` 开始消费共享类型和请求方法
- 共享包进入根级测试流程
- 薄封装迁移后现有功能没有漂移

### 4. 最后锁真实联调与测试主线统一

这一层要证明：

- 真实本地链路能跑通后台保存、发布和公开读取
- repo root 与数据库路径判断在 runtime 下是正确的
- `server` 单测与 E2E 已统一到 Vitest

## 七、这一轮最容易踩的坑

### 1. 一上来就把数据库和业务一起重写

这样会让教学重点极度分散，也更难定位问题是环境、schema 还是业务服务导致的。

### 2. 过早把简历拆成很多细表

这会把当前最值得讲的发布流和持久化主线，拖进复杂模型设计里。

### 3. 共享契约一步抽成“完整 SDK”

这样不仅改动过大，也会让读者失去对渐进迁移过程的理解。

### 4. 测试全过就以为真实链路没有问题

`M8` 最有价值的案例之一，就是推表库和运行库不一致的问题，只有真实本地冒烟才会暴露出来。

## 八、为什么 `M8` 完成后，项目才真正离“长期维护”更近一步

前面的里程碑已经把产品主线搭起来了。

而 `M8` 做的事情，是把这些主线第一次真正落到更稳定的基础设施上：

- 数据不再随服务重启消失
- 契约不再轻易漂移到不同 app 里
- 真实本地链路能反复冒烟验证
- 测试主线开始统一

这意味着项目终于不再只是：

- 能讲概念
- 能跑 demo

而开始变成：

- 能承受后续继续重构
- 能在更长周期里继续演进

## 九、如果你要自己复现 `M8`，建议按这个顺序

1. 先立数据库骨架和 CLI 入口
2. 再立最小持久化模型
3. 再把核心服务切到数据库实现
4. 再抽最小共享契约
5. 最后做真实本地冒烟，并统一测试主线

这个顺序的好处是，项目每往前走一步，都在先稳住更基础的一层：

- 数据库边界
- 持久化模型
- 业务服务实现
- 契约边界
- 真实验证与测试主线

## 十、这篇文章对应的原始材料

如果你想继续往下看每个 issue 的实现细节，可以配合这些开发日志一起阅读：

- `docs/30-开发日志/M8-issue-36-Drizzle-libsql-最小接入与环境边界.md`
- `docs/30-开发日志/M8-issue-37-简历草稿与发布快照表设计.md`
- `docs/30-开发日志/M8-issue-38-ResumePublicationService-持久化改造.md`
- `docs/30-开发日志/M8-issue-39-packages-api-client-最小共享契约.md`
- `docs/30-开发日志/M8-issue-40-admin-web-共享契约切换与冒烟验证.md`
- `docs/30-开发日志/M8-issue-41-Jest-迁移到-Vitest.md`
- `docs/40-教程与博客/M8-从内存态到可持续演进-阶段总结大纲.md`

这些记录更适合看“每一步具体做了什么”，而本文更希望回答“为什么 `M8` 要先收数据与契约边界，再继续往后走”。

## 小结

`M8` 做的不是一轮大功能扩张，而是先把项目从 demo 状态推进到更可持续演进的基础设施状态：

- 数据库边界成立
- 草稿 / 发布流持久化成立
- 共享契约开始收口
- 真实联调问题被收束
- 测试主线开始统一

这些边界一旦成立，后面的主题升级、共享 UI、AI 工作台和更完整后台体验，都会更像是在已有工程地基上继续增强，而不是重新把基础设施补一遍。
