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

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_atupdated_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

需要"不等于"或"在一组值里"时,用条件数组。当前只有三个算子equalnot_equalin,写别的会得到 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 不会报错,这条条件被静默忽略,于是查询范围悄悄变大
条件之间永远是 ANDfilter.match: "or" 会被接受但不生效。需要 OR 就分两次查再合并
in 传空数组不报错,返回 0 条
范围比较、模糊匹配、全文检索都不支持。按时间区间取数就把日期切成可等值匹配的字段;要搜索体验用外部搜索服务,不要把整表拉下来自己过滤

分页与排序

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

排序表达式用逗号分隔,每项是 <字段><字段> asc|desc。可以直接写的系统列有 idsortuser_idcreated_atupdated_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

先读、再判断、后写updatedelete 只认 id,不会替你校验这条记录属于谁。

ctx.db 抛出的是字符串,不是 Error 对象。catch (e)e.messageundefined,要看内容请用 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不要用邮箱当身份主键,也不要建 usersauth_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. 前缀。
  • 列表接口带 limitoffset,并按 total 翻页,没有全表拉取。
  • 没有 project_idsite_id、表 ID 进入浏览器 payload。

Render diagnostics