LLM 学习工作流(二):AI 数据表与 Supabase 持久化边界
这一步在做什么
这一步把 LLM 学习工作流从"只有内存状态"推进到了"有数据库边界"的阶段。
最终涉及的文件是:
supabase-schema.sqlsrc/utils/supabase.jstests/ai-schema.test.js
为什么这一步重要
如果没有持久化层,AI 学习工作流只能停留在前端页面里:
- 用户画像存不住
- 今日学习计划存不住
- 工作流运行状态存不住
- 事件日志存不住
而 AI 项目一旦进入"多步骤工作流",数据库边界就会直接决定后面好不好做。
这一步其实是在回答三个问题:
- 哪些状态要落库
- 前端如何通过统一接口访问这些状态
- 哪些数据允许用户改,哪些数据不能乱删
一开始做了什么
第一版是最直接的实现,对应提交:
d0c74e7efeat: add ai workflow schema and supabase helpers
当时新增了三张表:
user_ai_profiles
记录用户 AI 学习画像,例如:
- 当前水平
- 目标
- 每天可投入时间
- 关注主题
learning_workflow_runs
记录一次学习工作流的整体运行状态,例如:
- 当前阶段
- 计划 JSON
- 复盘 JSON
- 开始时间
- 完成时间
learning_workflow_events
记录工作流事件,用来做:
- 调试
- 回放
- 错误排查
- 日志追踪
同时在 src/utils/supabase.js 里加了最小 helper:
aiProfilesworkflowRuns
先写了什么测试
最开始的测试很简单,只验证"有没有这些东西":
assert.match(sql, /CREATE TABLE IF NOT EXISTS user_ai_profiles/i)
assert.match(content, /export const aiProfiles =/)
这类测试的优点是:
- 写得快
- 很适合做第一轮 TDD
- 能先把文件结构和接口壳子搭起来
但它的缺点很大:
- 只能证明"代码里写了这段文本"
- 不能证明"行为真的正确"
这也是为什么后面需要经过好几轮 review 才把它收紧。
第一轮评审发现了什么
问题 1:upsertProfile 看起来是 upsert,实际上可能会失败
最开始写的是:
upsert(payload)
但 user_ai_profiles 的唯一业务键其实是 user_id,不是主键 id。这意味着:
- 同一个用户第二次保存画像
- 很可能不是更新
- 而是直接撞唯一约束
第一轮修复,对应提交:
aad8453efix: tighten ai workflow schema helpers
修复内容:
- 给画像保存加上正确的冲突目标
- 去掉事件表的删除策略
- 测试开始检查这些关键点
第二轮评审发现了什么
问题 2:事件表看似禁止删除,实际上还能被级联删掉
虽然我们去掉了 learning_workflow_events 自己的 delete policy,但:
learning_workflow_events.run_id是ON DELETE CASCADElearning_workflow_runs还允许 delete
于是用户删掉 run,events 还是会一起被删掉。
这说明一件事:安全约束不能只看单表,要看整条关系链。
第二轮修复,对应提交:
dfc066ebfix: preserve append-only workflow history
修复内容:
- 去掉
learning_workflow_runs的 delete policy - 新增
workflowEventshelper - 给事件查询加上更明确的约束测试
第三轮评审发现了什么
问题 3:partial update 和并发创建是两类不同的问题
一开始你可能会觉得:
- 既然
upsert不稳,那就先查再改
这能解决"局部更新"的问题,但又引入了另一个问题:
- 两个并发请求可能同时看到"记录不存在"
- 然后都去 insert
- 最后其中一个仍然会撞唯一约束
所以真正更稳的做法是:
- 先按
user_id尝试update - 如果没更新到,再尝试
insert - 如果 insert 碰到唯一冲突,再回退到
update
这轮修复,对应提交:
8e49ae8efix: support partial ai profile updates
这个提交还顺手补了另一个马上会在 Task 3 撞到的问题。
问题 4:前端对象是 camelCase,数据库列是 snake_case
当前工作流工具 src/utils/learning-workflow.js 里返回的是:
{
currentStage: 'idle'
}
但数据库列名是:
current_stage
如果让页面直接把 JS 对象塞进数据库 helper,后面会马上出错。
所以这一步又加了一个很重要的概念:
边界序列化
也就是在 src/utils/supabase.js 里统一做字段映射:
currentStage -> current_stageplanJson -> plan_jsonreflectionJson -> reflection_jsonstartedAt -> started_atcompletedAt -> completed_at
这说明一件很关键的事情:
数据库层不应该把前端对象格式强行扩散到整个项目。
边界层的职责之一,就是做这种格式转换。
最终做成了什么
数据库层
最终的 schema 做到了:
- 三张表都建好了
- 关键索引存在
- 关键 RLS 策略存在
- 可变表有
updated_at触发器 runs/events不允许客户端删除
客户端层
最终的 Supabase helper 有:
aiProfiles.getProfile(userId)aiProfiles.upsertProfile(payload)workflowRuns.createRun(payload)workflowEvents.getRunEvents(runId)workflowEvents.addEvent(payload)
行为层
最终的关键约定是:
画像更新
- 局部更新可以工作
- 并发第一次创建更稳
- 不再依赖"看起来像 upsert"的假设
工作流 run 写入
- 页面继续使用 camelCase
- helper 自动序列化成数据库需要的 snake_case
事件读取
- 先按
created_at - 再按
id - 保证读取顺序更稳定
这一步真正学到的东西
这个任务表面上是在"加三张表",实际上你应该重点学下面这些东西。
1. 表设计只是开始,边界设计才是重点
很多人做数据库任务时,只关注:
- 表有没有
- 字段对不对
但 AI 项目的关键是:
- 前端对象怎么进数据库
- 数据库记录怎么回到前端
- 哪些操作允许发生
- 哪些操作必须禁止
这才是"持久化边界设计"。
2. upsert 不是你以为的"万能更新"
业务唯一键、主键、并发写入、局部更新,这几件事一混起来,upsert 很容易出坑。
所以以后你要养成一个习惯:
- 先问"我的唯一业务键是什么"
- 再问"调用方会传完整对象还是局部 patch"
- 最后再决定是不是用
upsert
3. 日志型数据要防止被间接删除
事件表不是普通业务表。
如果你把它作为:
- 调试日志
- 回放历史
- 排错证据
那就要非常警惕:
- 直接删除
- 间接删除
- 级联删除
4. 命名风格冲突应该在边界层消化
前端习惯 camelCase,数据库习惯 snake_case,这很正常。
错误做法是:
- 让前端 everywhere 都手动写 snake_case
更稳的做法是:
- 在边界 helper 里统一做序列化
这样上层业务代码会干净很多。
这一步你可以复现什么
跑全部 node 测试
node --test tests/*.test.js
跑构建
npm run build
看关键提交演进
git log --oneline -5
你会看到 Task 2 是怎么一步一步被 review 收紧的:
d0c74e7e初始 schema 和 helperaad8453e修画像 upsert 和事件删除策略dfc066eb修 append-only 历史和 event helper2763aa10修 partial update 与稳定排序8e49ae8e修并发创建与 camelCase/snake_case 边界
这一步你应该学会什么
- 如何为 AI 工作流设计持久化表
- 为什么 RLS 不能只看单张表
- 为什么事件日志不能随便让客户端删除
- 为什么数据库 helper 要负责字段映射
- 为什么"能跑"不等于"能安全集成"
- 一个 schema 任务是怎么被多轮评审收紧成更可靠实现的
下一步会做什么
下一步是 Task 3:
- 新增 AI 请求 payload 构造器
- 新增统一的 AI 调用入口
- 把"前端状态"整理成"发给 AI 编排层的标准请求体"
也就是说,下一步你会开始进入 AI 项目非常核心的一层:
前端状态 -> 结构化 payload -> 服务端 AI 工作流