直接定义:CRUD 就是 Create(增)、Read(查)、Update(改)、Delete(删)四件事。在 Supabase 里,你不用写 REST 路由去实现它们——PostgREST 已经把表直接变成带这四个动作的 HTTP 接口,supabase-js 只是把这层接口包成了带类型的函数。这一节我们把四件事逐个写通,并讲 RLS 怎么在背后默默守门。
先用一段 SQL 建一张 todos 表,并写一条"只能操作自己数据"的策略,这是后面 CRUD 安全的前提:
create table public.todos ( id bigint generated always as identity primary key, user_id uuid references auth.users(id) on delete cascade, content text not null, done boolean default false, created_at timestamptz default now() ); alter table public.todos enable row level security; create policy "自己的待办自己管" on public.todos for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
这里 for all 一次性覆盖增改删查,using 管读、with check 管写,都要过 auth.uid() = user_id。
const { data, error } = await supabase .from('todos') .insert({ user_id: (await supabase.auth.getUser()).data.user!.id, content: '写教程' }) .select() console.log(data) // [{ id: 1, content: '写教程', done: false, ... }] console.log(error) // null
.select() 让插入后返回刚写的行,方便前端直接拿到带 id 的对象。如果不加 .select(),默认只返回状态不返回数据。
PostgREST 的查询参数被 supabase-js 封装成链式方法。常用过滤:
// 查当前用户未完成的待办,按创建时间倒序,最多 10 条 const { data } = await supabase .from('todos') .select('id, content, done') .eq('done', false) .order('created_at', { ascending: false }) .limit(10) // 模糊搜索 const { data: hit } = await supabase .from('todos') .select('*') .ilike('content', '%教程%')
注意:RLS 已经把结果限定在当前用户,所以这里不用再手动加 user_id 过滤——加了是冗余,不加也安全。这正是 RLS 的价值:查询代码更干净,且不会因为"忘了写条件"而泄数据。
// 标记完成 await supabase.from('todos').update({ done: true }).eq('id', 1) // 删除 await supabase.from('todos').delete().eq('id', 1)
eq('id', 1) 是行定位条件。即便你写成 delete().neq('id', 0)(想删全部),RLS 的 using (auth.uid() = user_id) 仍会把范围收回到当前用户,别人家的待办删不掉。
下面 SVG 把"一次 CRUD 请求如何被 RLS 收窄"画出来,帮助你建立安全直觉:

背景:做一个团队待办,A 和 B 是同项目成员,但待办是私人拥有的。A 试图删 B 的一条待办。
操作过程:
delete().eq('id', B的待办id)。auth.uid() = user_id 生效:B的待办id 对应的 user_id 是 B,不等于 A 的 auth.uid()。结果:A 的越权删除静默失败,数据安全。
解读:很多人担心"返回空是不是信息泄露"。这里 RLS 返回 0 行,对外表现就是"没有符合条件的行",不会告诉 A"这条存在但归别人"——这点 Supabase 处理得比较克制。
变式:如果业务要"团队成员可见彼此待办但只能改自己的",就写两条策略:一条 for select using (team_id 相同) 放开读,一条 for update using (auth.uid() = user_id) 收紧写。读宽写窄是协作系统的常用模式。
提醒:用 service_role 密钥做 CRUD 会完全绕过 RLS,等于裸库操作。只在受信任的服务端(如带校验的 Edge Function)使用,且务必自行在代码里加权限判断。
建议:我们建议所有业务表默认 enable row level security 并至少一条策略,即使暂时 using (true) 全开放,也留好收紧的位置。空表无策略 + 未开 RLS 是新手最常见的数据裸奔原因。
真实业务里你不只做单行增删改。下面两招能避免常见性能与重复写入问题。
批量插入:一次网络往返插多行,比循环插快得多。
// 一次插入多条待办,仍是单次请求 const { data, error } = await supabase .from('todos') .insert([ { user_id: me, content: '写文档' }, { user_id: me, content: '改策略' }, { user_id: me, content: '跑测试' }, ]) .select() console.log(data?.length) // 3
UPSERT(存在则更新、不存在则插入),靠唯一约束避免重复:
-- 给 todos 的 (user_id, content) 加唯一约束,撑起 upsert alter table public.todos add constraint todos_user_content_uniq unique (user_id, content);
// 重复内容不会插出第二份,而是更新 done 字段 await supabase .from('todos') .upsert({ user_id: me, content: '写文档', done: true }) .select()
💡 关键直觉:凡是"循环里一次次调接口"的写法,都该先想能不能合成一条批量或 upsert。多一次往返就多一次延迟、多占一个连接,量上来就会变成 4.4 节讲的 N+1 慢查询。
背景:运营从 Excel 导了 500 条商品到 products 表,导了两次,结果出现两批完全相同的行,前端列表显示重复。
操作过程:
(sku) 加唯一约束,让重复无处容身:alter table public.products add constraint products_sku_uniq unique (sku);
await supabase .from('products') .upsert(rows.map(r => ({ sku: r.sku, name: r.name, price: r.price }))) .select()
delete 保留每组 sku 的第一条清掉:delete from public.products p where p.ctid not in ( select min(ctid) from public.products group by sku );
结果:重复清零,后续导入永不重复。
解读:重复数据本质是"缺唯一约束 + 用 insert 而非 upsert"。约束是数据库的硬保证,比在应用层先 select 再判断可靠——并发时应用层判断会漏。
⚠️ 常见坑:加唯一约束前表里已有重复数据,约束会建失败。务必先清重(如上 ctid 法)再 add constraint,否则约束一直挂不上。
下一节我们专门讲认证与授权:登录、第三方登录、以及 JWT 和 RLS 怎么咬合。