文档 / 搜索 / LingCode Cloud / 数据库
📘 参考 ● 数据 更新于 2026-06-11

数据库

一句话:每个后端都是一个独立 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 }。成功时 errornulldata 是行的数组。失败时 datanullerror{ 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 过滤,用户只看到属于自己的行。它在实时指南里有完整说明;这里不再重复。

关键约束:CRUD,而非 SQL

这一节值得读两遍。运行时数据 API 是表级 CRUD。三件在 SQL 里属于条件反射的事情,在运行时并不存在,而每一件都有一个干净的惯用做法来替代。来自 PostgREST 或原始 SQL 的人,正是在这里划出「一次写对 vs 400」的那条线。

没有 JOIN

你不能在一个查询里连接两张表。你有三个选项,大致按推荐程度排列:

之前(直觉的写法——在运行时行不通):

// ✗ 没有 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

没有 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);
}

200 行一页

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

所有 schema——表、索引、视图、约束、生成列、种子数据,以及 RLS 策略——都来自迁移。运行一次迁移有两种方式:

迁移运行完整 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-only)的。它接受读语句——SELECTWITHTABLEEXPLAINSHOW——并用 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 把守的运行时角色。写好你的策略,公开密钥就不带任何风险。

各档位限制

每个后端都有一个按套餐分档的表数量上限:

限制FreeProMax Pro
maxTables1050200

另外还有对 maxObjects(存储对象数)和 maxUsers 的分档限制——完整数字见定价页。超过任何上限会返回 HTTP 402,错误码为 quota_exceeded

错误码

error 非空时,code 告诉你发生了什么,status 携带 HTTP 状态码:

CodeStatus含义
where_required发起了一个不带过滤器的 updatedelete
table_not_found404表不存在(拼写错误,或者还没有迁移)
invalid_request400请求格式不正确
not_provisioned409后端还没上线——开通尚未完成
quota_exceeded402触及了某个分档上限(表、对象、用户)

底层机制(REST)

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 参考