JSON 表:定义、读写与查询
Func 持久化层的完整说明:表定义文件格式与落库前校验、记录的真实形状、where 与 filter 的算子及静默失效的陷阱、分页排序上限、合并式更新,以及「表属于 project 而不属于站点版本」这条容易踩空的边界。
JSON 表是 Func 的持久化层:一张表就是一份 JSON Schema 加一串记录,表定义放在项目文件里,读写只在 Func 里通过 ctx.db 完成。它刻意不是通用数据库——没有 join、没有聚合、没有事务、没有模糊匹配——能力范围正好覆盖预约、订单、报名、配额、日志这类站点自己的业务数据。
定义一张表
写入前表必须已经存在。表定义是项目文件 /platform/table/<key>.json,文件名就是表键——同一件事不在两处表达,所以文件内容里没有 key 字段。
// /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 |
| JSON 语法错误 | 报错带行列号,直接定位 |
记录的形状
Func 看到的一条记录,就是你写进去的 JSON 加上一个 id:
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
// /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(不抛错)。
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:
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。
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.<字段>"的提示。
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 会删掉这个字段。嵌套对象按整个键替换,不会逐层合并。
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 或平台工具管理。
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 后用样例输入自测,确认表和查询都对:
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 里实现登录。 - 需要过滤的值提升成顶层字段,并且写入时保证类型稳定;只用于展示的结构可以嵌套。
- 需要时间就自己存
createdAt字段,平台的时间戳读不到。 - 文件存 URL 和元数据,不要把 base64 存进表,见资源上传。
- 短期状态、计数器、限频用
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。
