12 · Supabase 数据库边界
把线程元数据接入 Supabase repository,把消息历史交给 LangGraph Supabase checkpointer,建立真实数据库边界。
课时资源
一、学习目标
本课所在阶段:第五阶段 · 真实产品基础能力。
学完这一课后,你应该能够:
- 理解为什么聊天系统进入产品阶段后,必须先抽出数据库边界,而不是把查询写进服务层
- 看懂为什么这一课里"消息历史"和"线程元数据"被拆给了两套不同机制
- 明白
chat.repository.ts为什么只保存线程元数据,而不再保存消息正文 - 说清
SupabaseSaver、memory.service.ts、chat.repository.ts三者分别负责什么
二、问题背景
到了第十一课,应用已经有了真实用户状态和受保护主页,但数据层还没有真正进入"产品模式"。
如果继续把线程信息和历史消息混在一起处理,会很快遇到两个问题:
- 服务重启后,数据边界不稳定
- 以后要接更严格的权限、RLS 或用户级查询时,很难知道该改哪一层
所以这一课真正要解决的,不是"把数据库 SDK 接进来"这么简单,而是先把两类数据职责拆开:
- 消息历史由 LangGraph checkpointer 负责
- 线程元数据由 repository 负责
三、核心概念
这一课最重要的概念是:Supabase checkpointer 管消息,repository 管线程元数据。
当前代码里的职责分工是这样的:
app/agent/chatbot.ts负责真实模型、工具调用和 LangGraph workflowSupabaseSaver负责把同一thread_id下的工作流消息状态存进 Supabaseapp/database/chat.repository.ts负责sessions表,也就是线程标题、创建时间、类型这些业务元数据app/services/memory.service.ts不再自己存数据,而是变成仓储层的业务门面
这条边界非常关键,因为它回答了一个常见误区:
- 不是"接了数据库,就应该所有聊天数据都放进一个 repository"
- 而是"谁适合由工作流状态管理,谁适合由业务仓储管理"
四、整体流程
五、Supabase 配置与数据库操作
这一课如果只看代码,很容易以为"已经 import 了 Supabase,就算接好了数据库"。实际上要让这一课真的跑起来,你还需要在 Supabase 控制台和 SQL Editor 里完成几步明确操作。
1. 先确认项目环境变量
当前代码会直接读取:
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your-publishable-key这两个变量和第 11 课保持一致,分别对应:
- Supabase 项目的
Project URL - Supabase 项目的
Publishable key
本课新增的重点不是换新变量,而是让这套 Supabase 配置同时服务于:
- repository
SupabaseSaver
2. 在 SQL Editor 先执行课程自己的 sessions 表脚本
打开 Supabase 控制台:
SQL Editor -> New query
先执行课程仓库里的:
这一步会创建当前课程真正关心的业务表:
public.sessions
以及相关索引:
idx_sessions_typeidx_sessions_created_at
这张表只负责线程元数据,不负责消息正文。
3. 再执行 checkpointer 官方迁移脚本
这一课一个很容易漏掉的点是:只执行 supabase-schema.sql 还不够。
因为消息历史并不是保存在 sessions 表里,而是由 @skroyc/langgraph-supabase-checkpointer 负责。所以你还需要再执行包里的官方迁移脚本:
node_modules/@skroyc/langgraph-supabase-checkpointer/migrations.sql也就是说,SQL 的执行顺序应该是:
- 先执行课程自己的
supabase-schema.sql - 再执行 checkpointer 包自带的
migrations.sql
这样职责是清楚的:
- 课程仓库维护业务表
- 依赖包维护 checkpoint 相关表、索引、RLS 和 RPC
4. 当前课为什么暂时关闭 sessions 表的 RLS
supabase-schema.sql 里现在有一行:
alter table public.sessions disable row level security;这不是最终产品配置,而是当前教学阶段的刻意放宽。
原因是:
- 第 12 课重点是先让数据库边界跑通
- 用户级隔离和更严格权限控制,后面还会继续收紧
- 如果这一课一开始就把 RLS、用户映射、策略全部拉满,学员会同时卡在太多层
所以这里是一个"先跑通,再收紧"的课程取舍。
5. 在 Table Editor 里验证到底写进了什么
配置完之后,最值得做的不是盲信代码,而是去 Supabase 控制台验证:
Table Editor -> sessions
你应该能看到:
- 新建对话后,
sessions表里出现一条新记录 id是线程 idname会从"新对话"逐步更新成首条问题摘要
但你不会在这里看到完整聊天正文。因为正文不在 sessions 表里,而是在 checkpointer 那套表里。
这一步验证很重要,因为它能帮你真正建立这节课最核心的边界感。
6. 这一课完成后,怎么判断数据库接入真的生效了
你至少可以做三组检查:
- 发送一条新消息后,
sessions表里出现新线程 - 刷新应用或重启 dev server 后,再次打开同一线程还能恢复历史
- 侧边栏列表和历史消息恢复都还能正常工作
如果这三件事同时成立,基本就说明:
- repository 在工作
SupabaseSaver在工作- 聊天主链路没有被数据库接入破坏
六、运行过程
1. Supabase 成为正式依赖,而不是可选扩展
app/database/supabase.ts 在模块初始化时就直接检查:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY
这说明从这一课开始,数据库不是"以后再接"的预留,而是课程默认环境的一部分。
2. 创建线程时先保证 sessions 表里有元数据
memory.service.ts 现在的 getOrCreateThread(...) 不再只是生成 id,而是会调用:
await ensureThread(id, firstPrompt?.slice(0, 24) || '新对话');也就是说,从请求一开始,线程元数据就已经进入了 repository 边界。
3. 消息历史恢复不走 repository,而走 checkpoint
当前 getChatHistory(threadId) 的核心是:
const state = await getChatApp().getState({
configurable: { thread_id: threadId },
});这一步直接说明:
- 历史消息由 LangGraph 状态恢复
- repository 不负责返回整段聊天正文
这正是这一课最重要的边界变化。
4. MemorySaver 升级成 SupabaseSaver
app/agent/chatbot.ts 里最核心的替换不是工作流本身,而是 checkpointer:
checkpointer = new SupabaseSaver(supabase);从这一行开始,工作流的线程状态不再保存在进程内存,而是进入 Supabase。
5. 线程标题和时间仍然单独由 repository 维护
chat.repository.ts 并不会去碰消息正文。它关心的是:
idnamecreated_attype
这正是侧边栏列表真正需要的业务元数据。
6. 数据库边界升级后,聊天主链路仍然保持稳定
这一课虽然讲数据库,但前端的:
- 模型选择
- 工具选择
- 多模态附件
- 流式事件
都继续保留。也就是说,这一课升级的是"数据落在哪里",不是"聊天怎么玩"。
七、关键代码解析
app/database/supabase.ts - 统一创建 Supabase 客户端。SupabaseSaver 和 repository 都共用这套配置。
app/agent/chatbot.ts - 本课最关键的工作流文件。这里把 MemorySaver 升级成 SupabaseSaver,让消息历史进入真实数据库。
关键代码:为什么 SupabaseSaver 是这一课真正的消息存储入口
function getCheckpointer() {
if (!checkpointer) {
checkpointer = new SupabaseSaver(supabase);
}
return checkpointer;
}代码解析:
这里的意义不只是"换了一个类",而是消息历史的管理权发生了转移:
- 之前消息历史更多依赖进程内存 checkpointer
- 这一课开始,消息状态进入 Supabase
- 这样服务重启后,线程上下文仍然能恢复
app/database/chat.repository.ts - 负责 sessions 表的读写,只管理线程元数据。
关键代码:为什么 repository 只管 sessions 表
export async function listThreadSummaries(): Promise<ChatSession[]> {
const { data, error } = await supabase
.from('sessions')
.select('id, name, created_at, user_id, type')
.eq('type', 'chat')
.order('created_at', { ascending: false });代码解析:
这个查询直接说明 repository 的职责边界:
- 它只返回侧边栏需要的线程摘要
- 它不试图拼出完整消息历史
- 这样"业务元数据"和"工作流状态"就不会重新混在一起
app/services/memory.service.ts - 从"内存持有者"变成"仓储门面",统一暴露 getOrCreateThread、touchThread、listThreads。
app/services/chat.service.ts - 继续负责流式事件编排,但这里已经开始等待异步仓储和数据库返回。
supabase-schema.sql - 课程自己的业务表脚本。当前只维护 sessions 表,checkpoint 迁移继续交给依赖包官方脚本。
关键说明:
-- 1. 课程仓库只维护业务表 sessions
-- 2. LangGraph Supabase checkpointer 相关表、索引、RLS 和 RPC
-- 请直接执行 node_modules/@skroyc/langgraph-supabase-checkpointer/migrations.sql代码解析:
这个做法很务实:
- 课程自己的业务表由课程仓库维护
- checkpoint 的底层表结构交给官方迁移脚本
- 这样可以减少"依赖升级后,课程仓库里的 SQL 漂移失真"这种维护成本
八、常见问题
为什么这一课不把所有数据都放进 repository?
因为消息历史本质上是 LangGraph 工作流状态,更适合交给 checkpointer;线程列表则是业务元数据,更适合交给 repository。
为什么 memory.service.ts 还保留这个名字?
因为它对上层暴露的仍然是"线程管理能力",只是底层实现已经从内存结构切换成了仓储调用。
为什么 sessions 表的 SQL 现在还比较宽松?
因为这节课重点是先打通数据库边界。更严格的用户隔离和权限控制,会在认证和后续权限设计里继续收紧。
为什么执行了 supabase-schema.sql 之后还不够?
因为这份脚本只创建课程自己的 sessions 表。LangGraph checkpoint 依赖的表和 RPC 还需要包里的官方 migrations.sql。
九、练习题
- 解释为什么"消息历史"和"线程元数据"不应该由同一个层统一负责。
- 如果后面要增加
favorites、folders之类业务字段,你会优先改SupabaseSaver还是chat.repository.ts?为什么? - 说一说
SupabaseSaver和sessions表在这一课里分别解决什么问题。
十、总结
这一课真正完成的是:把聊天系统的数据职责拆开了。
从这一课开始,线程元数据进入 repository,消息历史进入 SupabaseSaver,聊天应用才真正具备继续往"真实产品数据库结构"演进的基础。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。