一句话:每个后端都是一个独立 schema 里的私有 Postgres 数据库,带行级安全(RLS)。你的客户端从不打开 SQL 连接——它通过 SDK 与一层轻量的表级 CRUD 网关(一次一张表)对话(形态类似 Supabase / Firebase)。anon key(匿名密钥)可以安全地随浏览器发布,因为守门的是服务端的 RLS,而不是密钥的保密性。schema——表、索引、视图、约束、策略——来自迁移(migrations),从不通过运行时调用。运行时 API 是 CRUD,不是原始 SQL:没有 JOIN、没有 upsert、单页 200 行。
本页讲的是数据平面:你如何读写行,以及同样重要的——网关会做什么、不会做什么。如果你来自 PostgREST 或者习惯写原始 SQL,在动手写第一行代码之前最值得记住的一点是:运行时是刻意做成非 SQL 的。提前理解这条边界,正是「一次写对」与「拿到 400」之间的差别。我们先讲为什么,再讲怎么做。
一个 LingCode Cloud 后端是一个真正的 Postgres 数据库——但你不直接连接它。相反,SDK 与一个坐落在数据库前面的小型 CRUD 网关对话,它一次只接受针对一张表的一个操作。正是这个网关,让这套架构可以安全地放进浏览器打包产物里。
下面是这条推理链:
记住这条分界,绝大多数意外就消失了。运行时(你的应用在每个请求上做的事)是通过 SDK 的表级 CRUD(一次一张表)——没有 SQL。schema(你一次性搭好、或随时间演进的东西)是通过迁移的完整 SQL DDL。当某件事在运行时感觉做不到——一个 JOIN、一个聚合、一个唯一性保证——答案几乎总是「把它下推到迁移里」。
每个查询都从 client.from('table') 开始。它返回一个链式构造器:你叠加过滤器和修饰符,然后以一个真正发起请求并返回 promise 的终结动词结尾。在 /try 和 Mac 预览里,客户端已经以 window.lingcode 的形式存在于页面上;在你自己的应用里,你创建它一次(参见在自己的应用里使用 LingCode Cloud)。
const { data, error } = await lingcode
.from('todos')
.eq('done', false)
.order('created_at', { ascending: false })
.limit(50)
.select();
if (error) {
console.error(error.code, error.status);
} else {
render(data); // T[]
}
每个终结操作都解析为一个 Result:{ data, error }。成功时 error 为 null,data 是行的数组。失败时 data 为 null,error 为 { code, status }——一个稳定的字符串码加上 HTTP 状态码。永远检查 error;对于服务端拒绝的请求,SDK 不会抛出异常。
你想链多少过滤器都行——它们以 AND 组合在一起。每个都返回构造器,所以顺序无关紧要。
| 过滤器 | 含义 |
|---|---|
.eq(col, val) | 列等于值 |
.neq(col, val) | 列不等于值 |
.gt(col, val) | 大于 |
.gte(col, val) | 大于或等于 |
.lt(col, val) | 小于 |
.lte(col, val) | 小于或等于 |
.like(col, str) | 区分大小写的模式匹配 |
.ilike(col, str) | 不区分大小写的模式匹配 |
.in(col, [vals]) | 列是所列值之一 |
.is(col, null | 'not_null') | IS NULL / IS NOT NULL |
.match({ a: 1, b: 2 }) | 一次性写多个 .eq 过滤器的简写 |
| 修饰符 | 含义 |
|---|---|
.order(col, { ascending?: boolean }) | 按某列排序;默认升序 |
.limit(n) | 限制返回的行数 |
.range(from, to) | 返回一段闭区间的行切片(用于分页) |
动词发起请求。每个都返回 Promise<Result<T[]>>。
| 操作 | 作用 |
|---|---|
.select() | 读取匹配链式过滤器的行 |
.insert(row | row[]) | 插入一行或一个行的数组 |
.update(patch) | 对匹配的行应用一个部分补丁 |
.delete() | 删除匹配的行 |
.subscribe(onChange, onError?) | 打开一个对行变更的实时订阅 |
.update() 和 .delete() 在没有过滤器时拒绝运行。一个不带 .eq/.in/.match(等等)的补丁或删除会命中表里的每一行,所以网关会用 where_required 拒绝它,而不是让你不小心清空一张表。请始终把变更限定在你真正想要的那些行上。
// 更新——先过滤,再打补丁:
await lingcode.from('todos').eq('id', 7).update({ done: true });
// 删除——同样的规则:
await lingcode.from('todos').eq('id', 7).delete();
// 这个会抛出 / 被拒绝(没有过滤器):
await lingcode.from('todos').delete(); // → error.code === 'where_required'
.subscribe() 是进入实时的入口——订阅一张表上的 INSERT/UPDATE/DELETE,并按 RLS 过滤,用户只看到属于自己的行。它在实时指南里有完整说明;这里不再重复。
这一节值得读两遍。运行时数据 API 是表级 CRUD。三件在 SQL 里属于条件反射的事情,在运行时并不存在,而每一件都有一个干净的惯用做法来替代。来自 PostgREST 或原始 SQL 的人,正是在这里划出「一次写对 vs 400」的那条线。
你不能在一个查询里连接两张表。你有三个选项,大致按推荐程度排列:
.in() 取出子行,然后在 JavaScript 里组装成你想要的形状。select()。之前(直觉的写法——在运行时行不通):
// ✗ 没有 join。这不是网关能做的事。
const { data } = await lingcode
.from('todos')
.select('*, users(name)'); // → 不做关系展开
之后——在代码里拼合:
const { data: todos } = await lingcode.from('todos').select();
const userIds = [...new Set(todos.map(t => t.user_id))];
const { data: users } = await lingcode
.from('users')
.in('id', userIds)
.select();
const byId = Object.fromEntries(users.map(u => [u.id, u]));
const rows = todos.map(t => ({ ...t, user: byId[t.user_id] }));
之后——或者在迁移里把视图定义一次,然后像读表一样读它:
-- migration(运行一次,完整 DDL):
create view todos_with_user as
select t.*, u.name as user_name
from todos t
join users u on u.id = t.user_id;
// 运行时——对 SDK 来说这个视图就是一张表:
const { data } = await lingcode.from('todos_with_user').select();
没有 upsert,也没有 ON CONFLICT。惯用做法是:先 insert;遇到重复键错误时,再 fetch 并 update。
async function upsertProfile(profile) {
const ins = await lingcode.from('profiles').insert(profile);
if (!ins.error) return ins;
// 重复键 → 退回到对冲突行做 update。
return lingcode
.from('profiles')
.eq('user_id', profile.user_id)
.update(profile);
}
select() 最多返回 200 行。要取更大的范围,就用 .limit() 和 .range() 显式分页。而且因为运行时没有 JOIN 或聚合,计数和求和要么在你的代码里(对当前页)算,要么在迁移里创建的视图里算。
// 每次 200 行翻页取结果:
async function* allTodos() {
const PAGE = 200;
for (let from = 0; ; from += PAGE) {
const { data } = await lingcode
.from('todos')
.order('id', { ascending: true })
.range(from, from + PAGE - 1)
.select();
if (!data || data.length === 0) return;
yield* data;
if (data.length < PAGE) return;
}
}
如果一个查询需要 JOIN、需要 upsert、需要聚合,或者需要一次拿到超过一页的行——这就是一个信号,提示你把工作下推到迁移里(一个视图、一个生成列、一个唯一约束),或者在你的应用代码里循环处理。运行时网关会刻意保持简单。
所有 schema——表、索引、视图、约束、生成列、种子数据,以及 RLS 策略——都来自迁移。运行一次迁移有两种方式:
apply_migration。迁移运行完整 DDL。使用 IF NOT EXISTS,让迁移幂等、可安全地重跑:
create table if not exists todos (
id bigint generated always as identity primary key,
user_id uuid,
title text not null,
done boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists todos_user_idx on todos (user_id);
SELECT、WITH、TABLE、EXPLAIN、SHOW——并用 read_only_violation 拒绝任何写入或改变 schema 的语句。要运行 DDL 或 DML,请走迁移路径(SQL 标签页的迁移执行器,或 apply_migration),而不是这个临时查询编辑器。
完整的工作流——编写、排序、重跑迁移——参见管理数据库迁移。有了 schema 之后,运行 linter:用 advisors 扫描后端会在缺失的 RLS 策略、缺失的索引、缺失的主键在生产环境里咬到你之前先把它们标出来。
每个后端都活在它自己的 Postgres schema 里,配一个专属的低权限角色。运行时查询——SDK 用 anon key 发起的那些——以这个角色运行,并把已登录用户的 id 暴露给策略。正是这一点让「用户只能看到 user_id 等于自己 id 的行」这样的策略生效:网关运行你的查询,但 Postgres 会悄悄地把它过滤到策略允许的那些行。
-- 在迁移里:开启 RLS 并把行限定到各自的所有者
alter table todos enable row level security;
create policy todos_owner on todos
using (user_id = current_user_id());
控制台 / 所有者路径则不同:它绕过 RLS,让你可以从 backends.html 管理所有用户的每一行。这是特权那一侧——与你的应用使用的 anon-key 运行时路径不同。
这是 anon key 可以安全发布的另一半原因:它永远只能行使低权限、受 RLS 把守的运行时角色。写好你的策略,公开密钥就不带任何风险。
每个后端都有一个按套餐分档的表数量上限:
| 限制 | Free | Pro | Max Pro |
|---|---|---|---|
maxTables | 10 | 50 | 200 |
另外还有对 maxObjects(存储对象数)和 maxUsers 的分档限制——完整数字见定价页。超过任何上限会返回 HTTP 402,错误码为 quota_exceeded。
当 error 非空时,code 告诉你发生了什么,status 携带 HTTP 状态码:
| Code | Status | 含义 |
|---|---|---|
where_required | — | 发起了一个不带过滤器的 update 或 delete |
table_not_found | 404 | 表不存在(拼写错误,或者还没有迁移) |
invalid_request | 400 | 请求格式不正确 |
not_provisioned | 409 | 后端还没上线——开通尚未完成 |
quota_exceeded | 402 | 触及了某个分档上限(表、对象、用户) |
SDK 是对四个 REST 端点的轻量封装。基址是 https://lingcode.dev/api/cloud/be/<backend-id>,每个调用都携带 anon key——要么作为 Bearer token,要么作为 ?apikey= 查询参数。每个端点接受一个 JSON 请求体,描述表、过滤器,以及行或补丁。
| 端点 | SDK 动词 |
|---|---|
POST /select | .select() |
POST /insert | .insert() |
POST /update | .update() |
POST /delete | .delete() |
curl -X POST 'https://lingcode.dev/api/cloud/be/<backend-id>/select' \
-H 'Authorization: Bearer <your-anon-key>' \
-H 'Content-Type: application/json' \
-d '{
"table": "todos",
"filters": [{ "op": "eq", "col": "done", "val": false }],
"order": [{ "col": "created_at", "ascending": false }],
"limit": 50
}'
你几乎总会选择 SDK,它在这些端点之上给你一个链式构造器。完整的请求/响应结构见 API 参考。