# JSON 表：定义、读写与查询｜Creght AI 编程指南

> 在 Creght Func 里用 ctx.db 定义和读写 JSON 表：/platform/table 文件格式、schema 的校验时机、query 的 where 与 filter 算子、分页排序规则、update 的浅合并语义与常见陷阱。

[![Creght](https://ugc.talizen.com/_assets/site/2061660904709165056/1780797461299__creght_logo.png)API for AI](/)

[查看 llms.txt](/llms.txt)

概览

- [Creght AI 编程指南](/api.md)

AI 可发现性

- [如何优化 llms.txt](/api/optimize-llms-txt.md)

站点配置

- [实现基于域名的多语言路由](/api/domain-locale-routing.md)

后端

- [在服务端调用外部 API 并管理缓存](/api/ssr-external-api-cache.md)
- [使用 Func 构建站点后端能力](/api/func-backend.md)
- [JSON 表：定义、读写与查询](/api/func-json-tables.md)
- [上传文件：直传与 Func 内生成](/api/func-assets-upload.md)
- [超时配置与流式响应](/api/func-timeout-streaming.md)
- [使用 Func 接入支付宝电脑网站支付](/api/func-alipay-payment.md)

集成

- [使用集成发送邮件与验证码](/api/func-email-integration.md)
- [使用集成接入支付宝支付](/api/func-alipay-integration.md)

登录与用户

- [注册时验证邮箱](/api/auth-verified-registration.md)
- [实现找回密码与修改密码](/api/auth-password-reset.md)
- [在 Func 里实现登录](/api/auth-func-login.md)
- [在 Func 里查询用户](/api/func-user-directory.md)

本页目录

- [定义一张表](#define)
- [Schema 的作用范围与校验时机](#schema-rules)
- [保存表定义时：严格校验](#保存表定义时-严格校验)
- [写入记录时：不做校验](#写入记录时-不做校验)
- [记录的形状](#record-shape)
- [写入：insert](#insert)
- [读取：get 与 query](#read)
- [where：顶层字段等值](#where)
- [filter.conditions：不等于与 in](#filter)
- [分页与排序](#paging)
- [更新与删除](#update-delete)
- [用 CLI 管理表与记录](#cli)
- [约束与建模](#constraints)
- [表属于 project，不属于站点版本](#scope)
- [改名与删除都被刻意收紧](#lifecycle)
- [不能改名](#不能改名)
- [有记录时不能删表](#有记录时不能删表)
- [建模建议](#modeling)
- [验收清单](#checklist)

后端/JSON 表：定义、读写与查询

# JSON 表：定义、读写与查询

Func 持久化层的完整说明：表定义文件格式与落库前校验、记录的真实形状、where 与 filter 的算子及静默失效的陷阱、分页排序上限、合并式更新，以及「表属于 project 而不属于站点版本」这条容易踩空的边界。

复制 Markdown 链接

JSON 表是 Func 的持久化层：一张表就是一份 JSON Schema 加一串记录，表定义放在项目文件里，读写只在 Func 里通过 `ctx.db` 完成。它刻意不是通用数据库——没有 join、没有聚合、没有事务、没有模糊匹配——能力范围正好覆盖预约、订单、报名、配额、日志这类站点自己的业务数据。

**智能体目标**

先在 `/platform/table/<key>.json` 声明表，再在 Func 里用 `ctx.db` 读写；表键在代码里写成字面量，记录归属用平台 `user.id`，表 ID 和 `project_id` 永远不出现在代码和浏览器 payload 里。

## 定义一张表

写入前表必须已经存在。表定义是项目文件 `/platform/table/<key>.json`， **文件名就是表键**——同一件事不在两处表达，所以文件内容里没有 `key` 字段。

```json
// /platform/table/appointments.json
{
  "name": "Appointments",
  "desc": "已预约的时间段",
  "json_schema": {
    "type": "object",
    "properties": {
      "startAt": { "type": "string", "format": "date-time" },
      "day": { "type": "string" },
      "userId": { "type": "string" },
      "service": { "type": "string" },
      "status": { "type": "string", "enum": ["booked", "cancelled"] },
      "note": { "type": "string" }
    },
    "required": ["startAt", "day", "userId", "service"]
  }
}
```

顶层 **只接受三个字段**： `name`（必填，表的显示名）、 `desc`（可选）、 `json_schema`（必填）。多写任何一个字段都会被直接拒绝并列出允许的字段名—— `jsonSchema` 这类驼峰笔误因此在保存时就会暴露。表键本身必须匹配 `^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$`，且不能放进子目录。

> 这个文件是 **可写投影**，不是普通的站点源码：读它是把库里的表结构投影成 JSON，写它会走和控制台完全相同的保存路径。用普通文件工具创建和修改它即可，不需要单独的建表工具。

## Schema 的作用范围与校验时机

这一节决定了你的 Func 要写多少校验代码，值得先读清楚。

### 保存表定义时：严格校验

schema 本身写错会在保存时就被 400 拒绝，错误信息带行列号，可以直接照着改。

### 写入记录时：不做校验

`ctx.db.insert` / `update` **不会** 拿 schema 去校验记录。没声明的字段照样存得进去，缺少 `required` 字段也不会报错。

> 所以 `json_schema` 是 **结构声明**：它描述记录形状、驱动控制台的表格与表单、告诉后来的人（和智能体）这张表长什么样。 **运行时的数据正确性是 Func 自己的责任**——在写入前验证、裁剪、规范化输入，不要指望平台兜底。

保存表定义时实际执行的规则：

| 规则 | 写错时 |
| --- | --- |
| `type` 只能是 `object` | `"json_schema".type must be "object"` |
| `properties` 必填且非空，每个字段的值必须是对象 | `"json_schema".properties is empty; declare at least one field` |
| `required` 只能引用 `properties` 里声明过的字段 | 报错会把已声明的字段名一并列出——这是最常见的写法错误 |
| 不支持 `"format": "file"` | 改用 `{ "type": "string", "format": "uri", "contentMediaType": "image/*" }`，表里存的是 [上传后的 URL](/api/func-assets-upload.md) |
| JSON 语法错误 | 报错带 **行列号**，直接定位 |

## 记录的形状

Func 看到的一条记录，就是 **你写进去的 JSON 加上一个 `id`**：

```typescript
ctx.db.insert('appointments', { day: '2026-08-02', service: 'haircut' })
// → { day: '2026-08-02', service: 'haircut', id: 'a1b2c3d4' }
```

- `id` 是字符串主键，由平台生成， `get` / `update` / `delete` 都用它定位记录。 **不要声明业务字段 `id`**，它会被平台的 id 覆盖掉。
- **`created_at`、 `updated_at` 不会返回给 Func。** 需要创建时间就自己存一个业务字段——这是最容易踩空的一条。
- 记录另有一个 `sort` 权重，插入时自动取"当前最大值 \+ 10"，同样不出现在返回值里，但它是 **默认排序的第一个字段**，所以默认顺序是"后插入的在前"。
- 不要把 `id` 当业务语义。订单号、预约号这类要展示或对外传递的编号，作为普通字段自己生成。

## 写入：insert

```typescript
// /backend/func/booking.ts
import type { TalizenFuncContext } from 'talizen/func-runtime'

export function create(input, ctx: TalizenFuncContext) {
  // schema 不在写入时校验，所以校验写在这里
  const startAt = (input?.startAt ?? '').trim()
  if (!startAt) throw new Error('startAt is required')
  if (!['haircut', 'color'].includes(input?.service)) {
    throw new Error('unknown service')
  }

  const user = ctx.auth.requireUser()

  const inserted = ctx.db.insert('appointments', {
    startAt,
    day: startAt.slice(0, 10),
    userId: user.id,
    service: input.service,
    status: 'booked',
  })

  return { ok: true, id: inserted.id }
}
```

`ctx.db.*` 是 **同步** 的，写 `await` 也不会出错。第一个参数就是表键，平台在当前 project 下定位这张表；代码里不需要也不能拼接 `project_id`。

写入前先 `requireUser()`、再把 `user.id` 作为归属字段存下来——归属关系由服务端决定， **不要接受浏览器传上来的 `userId`**。

## 读取：get 与 query

按 id 取一条用 `ctx.db.get(model, id)`，记录不存在时返回 `null`（不抛错）。

```typescript
export function detail(input, ctx) {
  const appointment = ctx.db.get('appointments', input.id)
  if (!appointment) return { ok: false, reason: 'not_found' }

  // 私有数据必须自己校验归属，get 不做任何权限判断
  const user = ctx.auth.requireUser()
  if (appointment.userId !== user.id) throw new Error('forbidden')

  return { ok: true, appointment }
}
```

按条件查一批用 `ctx.db.query(model, query)`，返回 `{ total, list, limit }`： `total` 是满足条件的总数（可直接算总页数）， `list` 是本页记录， `limit` 是 **实际生效** 的每页条数——传超过上限的值会被静默截断，比对这个字段就能发现。

### where：顶层字段等值

最简单的查询形式，多个字段之间是 AND：

```typescript
export function mine(input, ctx) {
  const user = ctx.auth.requireUser()

  return ctx.db.query('appointments', {
    where: { userId: user.id, status: 'booked' },
    limit: 20,
  })
}
```

匹配的实现是 JSON 包含判断，有两个后果值得记住：

- **只能匹配顶层字段。** 嵌套对象里的值不能作为查询条件——需要按某个值筛选，就把它提升成顶层字段（上面示例里的 `day` 就是为此存在）。
- **类型必须完全一致。** `{ code: "1" }` 匹配不到存成数字 `1` 的记录。
- 字段值是数组时，等值匹配的语义是"数组里包含这个元素"，不是"数组完全相等"。

### filter.conditions：不等于与 in

需要"不等于"或"在一组值里"时，用条件数组。 **当前只有三个算子**： `equal`、 `not_equal`、 `in`，写别的会得到 `400 invalid operator`。

```typescript
export function board(input, ctx) {
  return ctx.db.query('appointments', {
    filter: {
      conditions: [
        { fieldId: 'status', operator: 'not_equal', value: 'cancelled' },
        { fieldId: 'service', operator: 'in', value: ['haircut', 'color'] },
      ],
    },
    limit: 50,
  })
}
```

| 容易踩的点 | 行为 |
| --- | --- |
| 字段名必须写 `fieldId` | 写成 `field_id` 不会报错，这条 **条件被静默忽略**，于是查询范围悄悄变大 |
| 条件之间永远是 AND | `filter.match: "or"` 会被接受但 **不生效**。需要 OR 就分两次查再合并 |
| `in` 传空数组 | 不报错，返回 0 条 |
| 范围比较、模糊匹配、全文检索 | **都不支持。** 按时间区间取数就把日期切成可等值匹配的字段；要搜索体验用外部搜索服务，不要把整表拉下来自己过滤 |

### 分页与排序

| 参数 | 说明 |
| --- | --- |
| `limit` | 返回条数， **默认 20，上限 1000**。超出会被静默截断，返回值里的 `limit` 是实际生效值 |
| `offset` | 分页偏移，没有 `page` 参数 |
| `order_by` | 排序表达式，默认 `sort desc, id desc`。 **必须写下划线**， `orderBy` 会被静默忽略 |

排序表达式用逗号分隔，每项是 `<字段>` 或 `<字段> asc|desc`。可以直接写的系统列有 `id`、 `sort`、 `user_id`、 `created_at`、 `updated_at`； **业务字段必须带 `body.` 前缀**，例如 `body.startAt desc`。漏写前缀会得到 400，并附带"是不是想写 `body.<字段>`"的提示。

```typescript
export function page(input, ctx) {
  const size = Math.min(Number(input.size) || 20, 100)
  const page = Math.max(Number(input.page) || 1, 1)

  const result = ctx.db.query('appointments', {
    where: { status: 'booked' },
    order_by: 'body.startAt asc, id desc',
    limit: size,
    offset: (page - 1) * size,
  })

  return { list: result.list, total: result.total, page, size }
}
```

翻页要读完整份数据时，一次一页地取，不要用循环把整表读进内存——那既会撞上执行超时，也会撑爆返回体积限制。

## 更新与删除

`ctx.db.update(model, id, data)` 是 **顶层浅合并**，不是整体替换： `data` 里出现的键覆盖旧值，没出现的键保持不变， **值传 `null` 会删掉这个字段**。嵌套对象按整个键替换，不会逐层合并。

```typescript
export function cancel(input, ctx) {
  const user = ctx.auth.requireUser()
  const appointment = ctx.db.get('appointments', input.id)

  if (!appointment) return { ok: false, reason: 'not_found' }
  if (appointment.userId !== user.id) throw new Error('forbidden')
  if (appointment.status === 'cancelled') return { ok: true, already: true }

  // 只改 status，其它字段原样保留；note: null 会把 note 删掉
  ctx.db.update('appointments', input.id, { status: 'cancelled', note: null })
  return { ok: true }
}
```

| 方法 | 返回 | 记录不存在时 |
| --- | --- | --- |
| `get` | 记录或 `null` | 返回 `null`，不抛错 |
| `insert` | 写入的对象 \+ `id` | — |
| `update` | `{ ok: true }`， **不返回更新后的记录** | 抛错（404 record not found） |
| `delete` | `{ ok: true }` | **不抛错**，同样返回 ok |

**先读、再判断、后写**： `update` 和 `delete` 只认 id，不会替你校验这条记录属于谁。

> `ctx.db` 抛出的是 **字符串**，不是 `Error` 对象。 `catch (e)` 里 `e.message` 是 `undefined`，要看内容请用 `String(e)`。

没有事务，也没有跨记录的原子操作。"同一个时间段只能被预订一次"这类约束，先查后写会留下并发缝隙；把互斥点收敛到 `ctx.cache.incr` 这类单点计数器上，或者接受重复后在业务上补一次去重。

## 用 CLI 管理表与记录

表结构可以直接改文件，也可以走 CLI；记录数据量大， **不以文件形式暴露**，只能通过 CLI 或平台工具管理。

```bash
creght table list   --site_id=<project_id>/<site_id>
creght table get    --site_id=<project_id>/<site_id> --key=appointments
creght table create --site_id=<project_id>/<site_id> --key=appointments \
  --name=Appointments --schema=./schema.json

creght table record list   --site_id=<project_id>/<site_id> --table=appointments \
  --where=./where.json --limit=20 --order_by='body.startAt asc'
creght table record create --site_id=<project_id>/<site_id> --table=appointments --data=./record.json
creght table record get    --site_id=<project_id>/<site_id> --table=appointments --id=<record_id>
creght table record update --site_id=<project_id>/<site_id> --table=appointments --id=<record_id> --data=./patch.json
creght table record delete --site_id=<project_id>/<site_id> --table=appointments --id=<record_id>
```

`--schema` 既接受一份裸 JSON Schema，也接受完整的表定义文件。写完 Func 后用样例输入自测，确认表和查询都对：

```bash
creght func run --site_id=<project_id>/<site_id> --key=booking.create --input=./input.json
```

## 约束与建模

### 表属于 project，不属于站点版本

这条最容易踩，因为它和"站点文件"的直觉相反：

- 表定义是 **project 级** 的活状态。同一个 project 下的多个站点 **共用同一批表**，改结构会同时影响它们。
- 表定义 **不进站点版本快照**。发布或回滚站点版本只影响站点文件， `/platform/` 下的东西不跟着回滚——回滚代码之后 schema 仍是新的，那是一个"从未存在过"的组合。
- 所以改 schema 要当成不可回滚的操作：先加字段、双写兼容，再改代码，最后清理，而不是指望回滚兜底。

### 改名与删除都被刻意收紧

### 不能改名

文件名就是表键，而 Func 代码按字面量引用它。改名会静默改掉后端行为，因此直接拒绝。要换名字：建新表、有意识地迁数据、再删旧表。

### 有记录时不能删表

空表可以删；表里还有记录时会带着条数拒绝，避免删一个文件顺手带走一整张表的数据。先删记录，或者去控制台操作。

### 建模建议

- 归属键用平台 `user.id`。 **不要用邮箱当身份主键**，也不要建 `users`、 `auth_users` 这类账号身份表——账号体系是平台能力，见 [在 Func 里实现登录](/api/auth-func-login.md)。
- 需要过滤的值提升成顶层字段，并且写入时保证类型稳定；只用于展示的结构可以嵌套。
- 需要时间就自己存 `createdAt` 字段，平台的时间戳读不到。
- 文件存 URL 和元数据， **不要把 base64 存进表**，见 [资源上传](/api/func-assets-upload.md)。
- 短期状态、计数器、限频用 `ctx.cache`，不要拿表当缓存。
- 等值过滤有索引，按业务字段排序没有，而且每次查询都会算一次 `total`。表的量级设想是"一个站点的业务数据"，不是日志仓库。

## 验收清单

- `/platform/table/<key>.json` 已存在，顶层只有 `name` / `desc` / `json_schema`。
- 输入校验写在 Func 里，没有把"schema 会拦住脏数据"当成前提。
- 写入路径调用了 `requireUser()`，记录里存的是 `user.id`。
- `get` / `update` / `delete` 之前校验了归属，而不是只按 id 操作。
- 查询条件字段写的是 `fieldId`，排序参数写的是 `order_by`，业务字段带 `body.` 前缀。
- 列表接口带 `limit` 与 `offset`，并按 `total` 翻页，没有全表拉取。
- 没有 `project_id`、 `site_id`、表 ID 进入浏览器 payload。

**完成标准**

表结构在文件里可读可 diff，输入校验在 Func 里显式写出，查询用得上索引并按页返回，每一次读写都能说清"这条记录属于谁、凭什么能动它"。

![Creght](https://ugc.talizen.com/_assets/site/2061660904709165056/1780797461299__creght_logo.png)

此网站使用 [Creght](/) 创建

![微信客服](https://fsu.creght.com/site/2066727200882692096/1785119134612__image.png)

微信客服

## 链接

- [价格](/price.md)
- [解决方案](/solution.md)
- [客户案例](/customers.md)
- [帮助中心](/help.md)
- [联系我们](/contact.md)
- [更新记录 & 博客](/blogs.md)
- [退款说明](/tuikuan.md)

## 资源

- [全部资源](/resources.md)
- [模板](/templates.md)
- [组件库](https://creghtlib.site.creght.com)
- [动效库](/design/effects.md)
- [Figma to Creght](/figma2creght.md)
- [API](/api.md)

## 产品对比

- [对比上线了](/creght-vs-sxl.md)
- [对比凡科建站](/creght-vs-fkw.md)
- [自己写代码 vs Creght](/compare/self-coding.md)
- [外包 vs 自己做](/compare/outsourcing.md)

## 协议

- [用户协议](/legal/terms.md)
- [隐私政策](/legal/privacy.md)
- [可接受使用政策](/legal/acceptable-use.md)

## 社交媒体

- [小红书](https://www.xiaohongshu.com/user/profile/5a38606811be10715f4895b6)
- [哔哩哔哩](https://space.bilibili.com/513308095)

[蜀ICP备2023038192号-2](https://beian.miit.gov.cn)
