$ cd ../blog
$ cat ~/blog/AI 应用开发/llm-workflow-02-ai-schema-supabase.mdx

LLM 学习工作流(二):AI 数据表与 Supabase 持久化边界

2026年4月20日·10 min read
<AI 应用开发 />
AILLMSupabase数据库工程实践

这一步在做什么

这一步把 LLM 学习工作流从"只有内存状态"推进到了"有数据库边界"的阶段。

最终涉及的文件是:

  • supabase-schema.sql
  • src/utils/supabase.js
  • tests/ai-schema.test.js

为什么这一步重要

如果没有持久化层,AI 学习工作流只能停留在前端页面里:

  • 用户画像存不住
  • 今日学习计划存不住
  • 工作流运行状态存不住
  • 事件日志存不住

而 AI 项目一旦进入"多步骤工作流",数据库边界就会直接决定后面好不好做。

这一步其实是在回答三个问题:

  1. 哪些状态要落库
  2. 前端如何通过统一接口访问这些状态
  3. 哪些数据允许用户改,哪些数据不能乱删

一开始做了什么

第一版是最直接的实现,对应提交:

  • d0c74e7e feat: add ai workflow schema and supabase helpers

当时新增了三张表:

user_ai_profiles

记录用户 AI 学习画像,例如:

  • 当前水平
  • 目标
  • 每天可投入时间
  • 关注主题

learning_workflow_runs

记录一次学习工作流的整体运行状态,例如:

  • 当前阶段
  • 计划 JSON
  • 复盘 JSON
  • 开始时间
  • 完成时间

learning_workflow_events

记录工作流事件,用来做:

  • 调试
  • 回放
  • 错误排查
  • 日志追踪

同时在 src/utils/supabase.js 里加了最小 helper:

  • aiProfiles
  • workflowRuns

先写了什么测试

最开始的测试很简单,只验证"有没有这些东西":

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。这意味着:

  • 同一个用户第二次保存画像
  • 很可能不是更新
  • 而是直接撞唯一约束

第一轮修复,对应提交:

  • aad8453e fix: tighten ai workflow schema helpers

修复内容:

  • 给画像保存加上正确的冲突目标
  • 去掉事件表的删除策略
  • 测试开始检查这些关键点

第二轮评审发现了什么

问题 2:事件表看似禁止删除,实际上还能被级联删掉

虽然我们去掉了 learning_workflow_events 自己的 delete policy,但:

  • learning_workflow_events.run_idON DELETE CASCADE
  • learning_workflow_runs 还允许 delete

于是用户删掉 run,events 还是会一起被删掉。

这说明一件事:安全约束不能只看单表,要看整条关系链。

第二轮修复,对应提交:

  • dfc066eb fix: preserve append-only workflow history

修复内容:

  • 去掉 learning_workflow_runs 的 delete policy
  • 新增 workflowEvents helper
  • 给事件查询加上更明确的约束测试

第三轮评审发现了什么

问题 3:partial update 和并发创建是两类不同的问题

一开始你可能会觉得:

  • 既然 upsert 不稳,那就先查再改

这能解决"局部更新"的问题,但又引入了另一个问题:

  • 两个并发请求可能同时看到"记录不存在"
  • 然后都去 insert
  • 最后其中一个仍然会撞唯一约束

所以真正更稳的做法是:

  1. 先按 user_id 尝试 update
  2. 如果没更新到,再尝试 insert
  3. 如果 insert 碰到唯一冲突,再回退到 update

这轮修复,对应提交:

  • 8e49ae8e fix: 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_stage
  • planJson -> plan_json
  • reflectionJson -> reflection_json
  • startedAt -> started_at
  • completedAt -> 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 和 helper
  • aad8453e 修画像 upsert 和事件删除策略
  • dfc066eb 修 append-only 历史和 event helper
  • 2763aa10 修 partial update 与稳定排序
  • 8e49ae8e 修并发创建与 camelCase/snake_case 边界

这一步你应该学会什么

  • 如何为 AI 工作流设计持久化表
  • 为什么 RLS 不能只看单张表
  • 为什么事件日志不能随便让客户端删除
  • 为什么数据库 helper 要负责字段映射
  • 为什么"能跑"不等于"能安全集成"
  • 一个 schema 任务是怎么被多轮评审收紧成更可靠实现的

下一步会做什么

下一步是 Task 3

  • 新增 AI 请求 payload 构造器
  • 新增统一的 AI 调用入口
  • 把"前端状态"整理成"发给 AI 编排层的标准请求体"

也就是说,下一步你会开始进入 AI 项目非常核心的一层:

前端状态 -> 结构化 payload -> 服务端 AI 工作流