--- title: 数据与存储 description: Gem 自己的数据:在清单里声明集合,用 ctx.data 读写记录、Log、文档和 Blob;查询与索引、事务、版本迁移、Gem Studio 测试、加密与上限。 section: dev order: 40 updated: 2026-11-05 --- # 数据与存储 gem 要长期保存的东西——业务记录、同步进度、生成的报告、图片——放在它自己的数据里。做法是:在清单里**声明**要存什么(集合、字段、索引、版本),工具用 `ctx.data` 读写。平台负责存储,按声明检查每一次写入,为每次修改留下记录(谁改的、改了什么),并要求大集合上的查询有声明的索引可用(见[索引规则](#索引规则))。你不写 SQL,也不用管表结构。 先要知道的几条边界: - **数据只属于这个 gem。** 别的 gem 读不到;卸载时整个删除,重新安装拿不到以前的数据。 - **存储不是权限。** 每个 gem、它的每个工具都有,清单里不用申请,用户也不用打开。 - **要给用户或别的 gem 看的东西,写进知识库**([知识库 API](/zh/dev/knowledge.md))。`ctx.data` 放的是 gem 自己的工作数据。 - **集合、Log、Blob 加密存放,文档是明文文件。** 见[加密与隔离](#加密与隔离)。 - **一个组织一份。** 同一个 gem 装在两个组织里,就是两份互不相通的数据。 `ctx.data` 提供这几样东西,下文逐一说明: | 接口 | 存什么 | | --- | --- | | `ctx.data.collection(name)` | 表:一条条可以修改、可以查询的记录 | | `ctx.data.log(name)` | Log:只追加、不修改的条目,比如每次的读数、每次的操作 | | `ctx.data.docs` | 文档:Markdown,可以按块编辑、按块引用 | | `ctx.data.blobs` | Blob:文件(图片、PDF……),按内容去重 | | `ctx.data.refs` | 引用:上面这些对象之间的链接 | | `ctx.data.transact(fn)` | 事务:几个写入要么全成,要么全不成 | 读写数据的方法都是异步的,要 `await`;`collection()`、`log()`、拼查询的方法和 `ctx.data.newId()` 是同步的。 ## 声明数据 打包后的清单(`notochord.json`)里,集合和 Log 写在顶层的 `data` 段。 ```json { "data": { "collections": { "notes": { "kind": "table", "fields": { "title": { "type": "string", "required": true }, "status": { "type": "string", "enum": ["open", "done"] }, "due": { "type": "timestamp" }, "project": { "type": "ref", "to": "projects" }, "photo": { "type": "blob" }, "tags": { "type": "array", "items": "string" }, "extra": { "type": "object" } }, "indexes": [["status", "due"], ["project"]] }, "projects": { "kind": "table", "fields": { "name": { "type": "string", "required": true, "unique": true }, "open_count": { "type": "integer" } } }, "readings": { "kind": "log", "fields": { "value": { "type": "number" }, "note": { "type": "ref", "to": "notes" } }, "retention": { "days": 365 } } } } } ``` - **`ctx.data` 不用申请**:每个工具都有,装好就能用。只用文档、Blob 和引用、不需要集合时,可以不写 `data` 段。 - **声明 `sideEffect: "read"` 的工具不要写任何存储**:计划模式会在用户批准之前运行这类工具,平台不检查它实际写了什么,见[权限模型](/zh/dev/permissions.md#存储不是权限)。 - Gem Studio 草稿的写法见[在 Gem Studio 里测试](#在-gem-studio-里测试)。 ### 集合 `data.collections` 里每一项是一个集合,键就是 `collection(name)`、`log(name)` 里用的名字。集合有两种:`table`(表)和 `log`。 | 键 | 表 | Log | 说明 | | --- | --- | --- | --- | | `kind` | `"table"` | `"log"` | 必填,以后不能改 | | `fields` | 必填 | 必填 | 1–128 个字段,见下 | | `description` | 可选 | 可选 | 最多 500 个字符 | | `version` | 可选 | 不能写 | 整数,默认 1,只能升不能降,见[版本与迁移](#版本与迁移) | | `indexes` | 可选 | 不能写 | 最多 16 个,每个是 1–8 个字段名的数组,见[索引规则](#索引规则) | | `migrations` | `version` 大于 1 时必填 | 不能写 | 迁移函数所在的文件 | | `retention` | 不能写 | 可选 | `{ "days": 1–36500, "maxEntries": 1–10000000 }`,至少写一个,见 [Log](#log) | 集合名和字段名:小写字母开头,只含小写字母、数字和下划线,最长 64 个字符。一个 gem 最多 64 个集合。 `data` 段里还可以写 `eventRetentionDays`(1–3650,默认 180):平台为每次修改留下的修改记录保留多少天。 ### 字段 | `type` | 值 | 能查询、排序、建索引 | | --- | --- | --- | | `string` | 字符串 | 能 | | `number` | 有限的数 | 能 | | `integer` | 安全整数 | 能 | | `boolean` | `true` 或 `false` | 能 | | `timestamp` | 毫秒时间戳(整数) | 能 | | `ref` | 某个表里一条记录的 id,`to` 写哪个表 | 能 | | `blob` | 已经存进来的 Blob 的哈希 | 能 | | `array` | 数组,`items` 写元素的类型 | 只能用 `array-contains` 查询,而且要写了 `items` | | `object` | 普通对象,内容不检查 | 不能 | | `any` | 任意 JSON | 不能 | 前七种叫标量字段。字段上还可以写: | 键 | 说明 | | --- | --- | | `required` | 必填。 | | `unique` | 值不能重复。只能用在表的标量字段上,Log 不能用。 | | `enum` | 允许的值,1–256 个、不重复,类型要和字段一致。只能用在 `string`、`number`、`integer` 上。 | | `to` | `ref` 字段(和 `items` 为 `ref` 的数组)必填:指向的表的名字,必须是同一个清单里的 `table`。 | | `items` | 只能用在 `array` 上,是标量类型之一。可以不写:不写就不检查元素,但也不能用 `array-contains` 查询。 | | `description` | 最多 500 个字符。 | 写入的数据不合声明就被拒绝(`E_INPUT_INVALID`),平台不会悄悄转换:类型不对、缺必填字段、出现没有声明的字段、`blob` 字段里的哈希还没有存进来,都会报错。`ref` 字段只检查 id 的格式,不检查那条记录是否存在。`object`、`any` 和不写 `items` 的数组最多嵌套 32 层。 只有一个约定:**值为 `null` 等于没有这个字段**。插入时值为 `null` 的字段被丢掉,更新时把字段设成 `null` 就是删除它。 `data` 段写得不对时(名字不合规、`to` 指向不存在的表、`version` 大于 1 却没写 `migrations`……),清单校验不通过,报 `E_PERM_INVALID`。打包、安装和 Gem Studio 测试时都会检查。打包和安装时,形状上的错误(不认识的键、`type` 写错、说明超长)先被清单的 JSON Schema 拦下,消息是 `notochord.json failed schema validation: …`;其余的错误(比如上面举的几种)消息以 `data:` 开头。Gem Studio 测试时都以 `data:` 开头。 ## 集合:写入与查询 ```js export default async function (input, ctx) { const notes = ctx.data.collection('notes') const { id } = await notes.insert({ title: input.title, status: 'open', due: Date.now() }) const note = await notes.get(id) // { id, version, createdAt, updatedAt, data },没有时是 null // 按顶层字段合并,值为 null 的字段被删除;ifVersion:只在没人改过时才改 await notes.update(id, { status: 'done', due: null }, { ifVersion: note.version }) const page = await notes.where('status', '==', 'open').orderBy('due').limit(20).get() // page = { items: [...], nextCursor?: '...' } if (page.nextCursor) { const next = await notes.where('status', '==', 'open').orderBy('due').limit(20).startAfter(page.nextCursor).get() } const open = await notes.where('status', '==', 'open').count() return { open } } ``` | 方法 | 说明 | | --- | --- | | `insert(data, { id? })` | 新建一条记录,返回 `{ id, version, nodeId }`。不给 `id` 时自动生成;给的 `id` 已经存在时报 `E_DATA_CONFLICT`。id 只能含字母、数字、`_` 和 `-`,最长 128 个字符。 | | `get(id)` | `{ id, version, createdAt, updatedAt, data }`,没有这条记录时是 `null`。 | | `update(id, patch, { ifVersion? })` | 按顶层字段合并:`patch` 里的字段替换原值,值为 `null` 的字段被删除,合并后的记录再整体按声明检查一遍。返回 `{ version }`。记录不存在时报 `E_DATA_NOT_FOUND`。 | | `delete(id, { ifVersion? })` | 返回 `{ deleted: true \| false }`,记录本来就不存在时是 `false`,如果带了 `ifVersion` 则报 `E_DATA_CONFLICT`。 | | `where(field, op, value)`、`orderBy(field, 'asc' \| 'desc')`、`limit(n)`、`startAfter(cursor)` | 拼一条查询。每一步返回一个新的查询,调用 `get()` 或 `count()` 时才执行。 | | `get()` | 执行查询,返回 `{ items, nextCursor? }`,`items` 的每一项和 `get(id)` 返回的一样。 | | `count()` | 符合条件的记录数。只看 `where` 条件,排序、`limit` 和 `startAfter` 都不起作用。 | - **`version`** 从 1 开始,每次写入加 1。`update`、`delete` 带上 `ifVersion` 时,如果记录在你读到之后被改过,就报 `E_DATA_CONFLICT`,什么也不改。 - **删除是软删除**:记录从接口里消失,内容还留在数据文件夹里,卸载时才删除,见[加密与隔离](#加密与隔离)。删掉的 id 可以再用来插入。 ### 查询规则 - 运算符:`==`、`!=`、`<`、`<=`、`>`、`>=`、`in`(1–30 个值)、`array-contains`。`where('due', '==', null)` 找没有这个字段的记录,`!= null` 找有的。 - 只有标量字段能过滤和排序,比较的值要和字段的类型一致;`array` 字段只能用 `array-contains`。 - 一条查询最多 16 个条件、8 个排序字段,`in` 最多一个。 - 范围条件(`<`、`<=`、`>`、`>=`、`!=`)只能用在一个字段上;有 `orderBy` 时,第一个排序字段必须是它。 - 所有 `orderBy` 的方向相同。 - 有 `in` 条件的字段如果也参与排序,只能是第一个排序字段。 - 不写 `orderBy` 时:有范围条件就按那个字段排,否则按 id 排。 - **缺少某个字段的记录**,不会出现在按这个字段排序的结果里,也不满足这个字段上的范围条件和 `!=`(`!= null` 除外)。 - 字符串按 Unicode 码位比较,区分大小写。 - `limit` 默认 100,最多 1000。一次结果最多 16 MB,超过报 `E_PAYLOAD_TOO_LARGE`,把 `limit` 调小。 - 还有下一页时,结果里有 `nextCursor`,把它原样传给同一条查询的 `startAfter`。 ## 索引规则 集合里的记录**超过 1000 条**以后,每条查询都必须有一个用得上的已声明索引,否则报 `E_DATA_NEEDS_INDEX`,消息里直接写着要加哪个索引: ``` E_DATA_NEEDS_INDEX: query on 'notes' needs an index: add ["status","due"] to data.collections.notes.indexes ``` 1000 条以内不检查,所以开发时一切正常,等用户的数据多了才出错。**从一开始就把每条查询需要的索引声明好。** 一个索引能用于一条翻页查询(`get()`),当且仅当它的字段**正好**是: 1. 这条查询里 `==` 和 `in` 条件的字段,顺序随意; 2. 接着是它的排序字段,顺序和 `orderBy` 一致。只有范围条件、没写 `orderBy` 时,排序字段就是那个范围字段;被 `==`(`== null` 除外)或 `in` 固定的字段不算排序字段。 多一个字段、少一个字段都不行。`count()` 宽松一些:索引以 `==`、`in` 的字段开头(顺序随意),紧接着是范围字段(如果有)就可以,后面再有别的字段也没关系。 | 查询 | 用得上的索引 | | --- | --- | | `where('status', '==', 'open').orderBy('due').get()` | `["status", "due"]` | | `where('status', '==', 'open').get()` | `["status"]`(`["status", "due"]` 不行) | | `where('due', '<', t).get()` | `["due"]` | | `orderBy('due', 'desc').get()` | `["due"]` | | `where('project', '==', p).where('status', 'in', ['open', 'done']).orderBy('due').get()` | `["project", "status", "due"]` 或 `["status", "project", "due"]` | | `where('status', '==', 'open').count()` | 以 `"status"` 开头的任何索引,比如 `["status", "due"]` | | 没有条件、没有排序(按 id 翻页) | 不需要索引 | - **`array-contains` 用不上索引**(数组不能建索引)。在大集合上,它要和一个有索引的 `==` 或范围条件一起用;单独用会报 `E_DATA_NEEDS_INDEX`。 - **`unique` 只管不重复,查询用不上它。** 要按 `unique` 字段查询,也要在 `indexes` 里声明它。 - 只有标量字段能建索引。增删索引不用升版本,新的索引在 gem 下次启动时(安装、升级后)建好。 - **每次调用都有扫描上限**:一次翻页查询最多读约 64 MB 的记录,一次计数最多数 100 万条,超过也报 `E_DATA_NEEDS_INDEX`。命中的记录太多时,加条件缩小范围。 ## 事务 ```js const noteId = ctx.data.newId() await ctx.data.transact(async (tx) => { const project = await tx.get('projects', input.projectId) // 读:马上执行,记下版本 if (!project) throw new Error('没有这个项目') // 抛错:什么都不提交 tx.update('projects', input.projectId, { open_count: (project.data.open_count ?? 0) + 1 }) // 写:先收集 tx.insert('notes', { title: input.title, project: input.projectId }, { id: noteId }) tx.log('readings').append({ value: 1, note: noteId }) }) // 函数返回后一起提交 ``` - 事务里能用:`get`、`insert`、`update`、`delete`、`log(name).append`、`link`、`unlink`。只有 `get` 是异步的、马上执行;写操作先收集起来,函数返回后一次提交,最多 500 个。 - **函数里读不到它自己还没提交的写入。** - 提交时,平台检查函数读过的记录有没有被别人改过(包括被删除,或者读的时候还不存在、现在有了)。改过就**重新运行整个函数**,默认最多 5 次(`transact(fn, { maxAttempts })` 可以改),还不行就抛 `E_DATA_CONFLICT`。所以函数里不要做有外部副作用的事:发请求、写文件、调模型,都放在事务外面。 - 函数抛错时什么都不提交,错误原样抛出;函数正常返回时,`transact` 返回它的返回值。 - 要在事务里预先知道新记录的 id,用 `ctx.data.newId()`。 - 文档和 Blob 的操作不能放进事务,它们各自是单次原子的。事务外的每一次写入也都是原子的。 ## Log Log 存只追加、不修改的条目,比如每次的读数、每次处理过的邮件。 ```js const readings = ctx.data.log('readings') await readings.append({ value: 4.2, note: noteId }) // { id, nodeId } const { items, nextCursor } = await readings.query({ since: Date.now() - 86400e3, limit: 100 }) // items:[{ id, at, data }],默认新的在前 ``` | 方法 | 说明 | | --- | --- | | `append(entry)` | 追加一条。和表一样按声明的字段检查,最大 1 MiB。返回 `{ id, nodeId }`。 | | `query({ since?, until?, limit?, order?, startAfter? })` | `since`、`until` 是毫秒时间戳,包含两端;`order` 默认 `'desc'`(新的在前);`limit` 默认 100,最多 1000。返回 `{ items: [{ id, at, data }], nextCursor? }`,`at` 是追加的时间。一次结果最多 16 MB。 | - **没有修改、删除单条的接口。** 旧条目由平台按 `retention` 清理:`days` 按时间,`maxEntries` 按条数(保留最新的),两个都写时都生效。不写 `retention` 就一直保留到卸载。清理每天最多一次。 - Log 不能写 `version`、`indexes`、`migrations`,字段也不能 `unique`。它的字段声明只管以后写入的条目,可以随时改,不用升版本;旧条目保持写入时的样子。 - 条目里的 `ref`、`blob` 字段和表里的一样自动维护引用:被条目引用的 Blob 不会被清理,直到条目本身被清理。 - 在事务里追加:`tx.log(name).append(entry)`。 ## 文档 文档是 Markdown,按块(段落、标题、列表、代码块……)编辑,每块有一个稳定的 id。 ```js const { id, blockIds } = await ctx.data.docs.create({ title: '规格书', markdown: '# 规格\n\n油 50%。' }) const doc = await ctx.data.docs.get(id) // { id, title, markdown, blocks: [{ id, type, markdown }], createdAt, updatedAt } await ctx.data.docs.replaceBlock(id, blockIds[1], '油 55%。') await ctx.data.docs.insertAfter(id, blockIds[1], '新的一段\n\n再一段') await ctx.data.docs.deleteBlock(id, blockIds[0]) ``` | 方法 | 说明 | | --- | --- | | `create({ id?, title?, markdown })` | 新建一篇,返回 `{ id, nodeId, blockIds }`。 | | `get(id)` | 返回 `{ id, title, markdown, blocks, createdAt, updatedAt }`,没有时是 `null`。`markdown` 是整篇正文,不带块 id;`blocks` 的每一项是 `{ id, type, markdown }`。 | | `list({ limit?, startAfter? })` | `{ items: [{ id, title, updatedAt }], nextCursor? }`。 | | `update(id, markdown, { title? })` | 整篇替换,内容没变的块保留原来的 id。返回 `{ blockIds }`。 | | `insertAfter(docId, blockId, markdown)` | 在某一块后面插入,`markdown` 可以有好几块;`blockId` 为 `null` 时插到最前。返回新块的 `{ blockIds }`。 | | `replaceBlock(docId, blockId, markdown)` | 替换一块;新内容有好几块时,第一块沿用原来的 id。返回 `{ blockIds }`。 | | `deleteBlock(docId, blockId)` | 删除一块。 | | `delete(id)` | 返回 `{ deleted: true \| false }`。 | - 块 id 形如 `b_…`,编辑后不变,可以被引用(见[引用与节点 id](#引用与节点-id))。块的 `type` 是 `heading`、`paragraph`、`list`、`code`、`quote`、`table`、`thematic_break` 或 `html`。 - 每篇文档是数据文件夹里的一个 `.md` 文件,块之间用空行分开,每块的 id 以 `{#b_…}` 的形式写在这块最后一行的末尾(代码块写在开头的那一行,setext 标题写在文字那一行)。文件在应用外被改了,下次读取时平台会发现,按文件重建块。 - **文档文件不加密。** 敏感内容放进集合,不要放进文档。 - 一篇最大 2 MiB,标题最多 500 个字符。id 的规则和记录一样;只差大小写的两个 id 不能同时存在(后建的报 `E_DATA_CONFLICT`),删掉的文档也算(软删除的文档一直占着它的 id,直到卸载);同一个 id、大小写也相同的可以重新建。`get()` 按 id 精确匹配,大小写不同就找不到。 - 要改的文档或块不存在时报 `E_DATA_NOT_FOUND`。 - 文档的操作不能放进事务,每次调用各自是原子的。 - 删除是软删除:`.md` 文件留在磁盘上,直到卸载。 ## Blob Blob 存文件:图片、PDF、音频…… ```js const { hash } = await ctx.data.blobs.put(base64, { mime: 'image/jpeg', name: 'photo.jpg' }) // { hash, size, nodeId } await ctx.data.collection('notes').update(id, { photo: hash }) const blob = await ctx.data.blobs.get(hash) // { base64, mime, name, size },没有时是 null const info = await ctx.data.blobs.stat(hash) // { mime, name, size, createdAt },没有时是 null ``` - 内容以 base64 传进传出,一个最大 20 MiB。哈希是内容的 SHA-256(64 位小写十六进制),同样的内容只存一份。 - **先 `put`,再把哈希存进 `blob` 字段。** 写进 `blob` 字段的哈希必须已经存进来,否则报 `E_INPUT_INVALID`。 - **没有删除接口。** 没有任何地方引用的 Blob(记录或 Log 条目的 `blob` 字段,或者用 `refs.link` 指向它),在最近一次 `put` 满 7 天后由平台清理。同样的内容再 `put` 一次,7 天重新计算;`stat()` 的 `createdAt` 就是最近一次 `put` 的时间。 - Blob 加密存放,不能放进事务。 ## 引用与节点 id gem 的每个对象都有一个节点 id: ``` gem:<gemId>/rec/<collection>/<id> 一条记录 gem:<gemId>/doc/<docId> 一篇文档 gem:<gemId>/doc/<docId>#<blockId> 文档里的一块 gem:<gemId>/blob/<sha256> 一个 Blob gem:<gemId>/log/<name>/<entryId> 一条 Log 条目 ``` `insert`、`docs.create`、`blobs.put` 和 `log(name).append` 都返回 `nodeId`。也可以自己构造: ```js const noteNode = await ctx.data.nodeId({ kind: 'rec', collection: 'notes', id }) const blockNode = await ctx.data.nodeId({ kind: 'doc', docId, blockId }) // 不写 blockId 是整篇文档 // 另外两种:{ kind: 'blob', hash }、{ kind: 'log', name, eventId }(eventId 是 append 返回的 id) await ctx.data.refs.link(noteNode, blockNode, 'cites') await ctx.data.refs.backlinks(blockNode) // 谁引用了它:[{ from, to, kind, detached, createdAt }] await ctx.data.refs.outgoing(noteNode) // 它引用了谁 await ctx.data.refs.unlink(noteNode, blockNode, 'cites') ``` | 方法 | 说明 | | --- | --- | | `link(from, to, kind)` | 建一条引用,返回 `{ linked }`;同样的引用已经存在时是 `false`。 | | `unlink(from, to, kind)` | 删一条引用,返回 `{ unlinked }`。 | | `backlinks(to, { kind?, limit? })` | 指向 `to` 的引用。`limit` 默认 100,最多 1000。 | | `outgoing(from, { kind?, limit? })` | 从 `from` 出发的引用。 | - `kind` 是你自己起的名字,匹配 `^[a-z][a-z0-9_.:-]{0,63}$`。以 `field:` 开头的留给平台:`ref` 和 `blob` 字段会自动维护引用(`kind` 为 `field:<字段名>`),不用自己 `link`。 - 目标被删除时,指向它的引用不会被删,而是标记 `detached: true`;目标重新出现时恢复。引用一个还不存在的对象也可以,同样标记为 `detached`。 - **只能引用本 gem 的对象**,用了别的 gem 的节点 id 报 `E_PERM_DENIED`。 - `link`、`unlink` 是写入:声明为只读的工具不要用;它们可以放进事务。 ## 版本与迁移 表的字段可以改,但已经存下的记录必须仍然合规。所以: - **同一个版本里只能「放宽」**:加可选字段、去掉 `required` 或 `unique`、扩大 `enum` 或去掉 `enum`、增删索引、改说明。加 `unique` 也可以,但已有重复值时 gem 启动失败(`E_DATA_UNIQUE`)。 - **其余改动要升 `version` 并写迁移**:删字段、改 `type`、改 `to` 或 `items`、给已有字段加 `required`、缩小 `enum` 或给原来没有 `enum` 的字段加上、加一个必填的新字段。在同一个版本里做这些改动,gem 启动时报 `E_DATA_SCHEMA`。 - **集合的 `kind` 不能改;`version` 不能降。** - Log 不受这些限制,字段可以随时改。 - 从清单里删掉的集合,数据保留但接口访问不到,卸载时删除;之后再加回来,旧数据还在(同样按上面的规则检查)。 - 升级和同版本重装都保留数据。 ```json "notes": { "kind": "table", "version": 2, "migrations": "migrations/notes.js", "fields": { "title": { "type": "string", "required": true }, "status": { "type": "string", "enum": ["open", "done"] } } } ``` ```js // migrations/notes.js:每一步一个函数,v1_to_v2、v2_to_v3…… export function v1_to_v2(data, { id }) { return { title: data.title, status: data.done ? 'done' : 'open' } } ``` - **迁移在 gem 升级后启动时运行**:平台在沙盒里对每条记录依次运行需要的步骤(从版本 1 升到 3,就先 `v1_to_v2` 再 `v2_to_v3`),结果按新的声明检查后写回。只迁移没有删除的记录。每条记录的迁移都留下一条修改记录。 - **每一步收到一条记录的 `data` 和 `{ id }`,必须返回一个普通对象**,所以迁移不能删除记录。 - **迁移函数必须每次都得到同样的结果**:迁移可能中断后重跑。它没有 `ctx`,调用 `Date.now()`、不带参数的 `new Date()` 或 `Math.random()` 会直接报错。 - `migrations` 是包里的一个 `.js`、`.mjs` 或 `.ts` 文件,路径里不能有 `..`;`version` 大于 1 时必须写,安装时检查它确实在包里。缺少需要的某一步,升级在改动任何数据之前就失败。 - **时限**:每一批(最多 500 条记录)60 秒,整个迁移 10 分钟。 - **任何一步失败**(函数抛错、结果不合新声明、缺函数、超时),**整个升级回滚**:数据恢复原样,旧版本继续运行。用户看到的是 `E_ACTIVATION_FAILED`,原因(`E_DATA_SCHEMA: …`)包在消息里。 - 迁移成功后,数据文件夹里保留一份迁移前的 `data.db` 副本(只留最近的一份,同样加密)。 ## 在 Gem Studio 里测试 ### 草稿里怎么写 Gem Studio 草稿的 `manifest.json` 和打包后的清单写法不同: ```json { "data": { "collections": { "notes": { "kind": "table", "fields": { "title": { "type": "string", "required": true } } } } }, "exports": [ { "name": "save_note", "kind": "tool", "entry": "tools/save_note.js", "...": "..." }, { "name": "list_notes", "kind": "tool", "entry": "tools/list_notes.js", "side_effect": "read", "...": "..." } ] } ``` - 存储不用写:概览的权限里没有存储,工具页上也没有。 - 顶层的 `data`:写法和打包后的清单完全一样,要在 `manifest.json` 里手写。 - 只读的工具写 `"side_effect": "read"`。 - `data` 段写错时,测试和安装一样报 `E_PERM_INVALID`(`data: …`)。 ### 测试数据 - **测试用的是这个草稿自己的测试数据**:单独测试工具、agent 的测试对话、测试 SOP,用的是同一份。它和从 Gem Studio 安装出去的那个 gem 的数据完全分开,互不影响。 - **测试数据一直保留**,跨测试,也跨应用重启。只有 `data` 段或 `db/` 下的文件变了,下一次测试才按新的声明重新打开它。 - **所以迁移在下一次测试时运行**:在草稿里升 `version`、写好迁移文件,然后运行任意一次测试。建议先用旧版本写一些数据,再升版本,看迁移后的数据对不对。 - **升了版本就退不回去**:测试数据迁移到新版本以后,把 `version` 改回去会报 `E_DATA_SCHEMA`。 - **迁移成功以后再改迁移文件,什么也不会发生**——数据已经是新版本了。要修正一个写错的步骤,再升一个版本,把修正写成下一步。 - 迁移失败时,测试数据保持原样,下一次测试会再试一次。 - **只有删除草稿才会清掉它的测试数据**(连同它的密钥)。 - 想像用户那样测一次升级:从 Gem Studio 安装,用装好的 gem 写些数据,然后在草稿里提高集合的 `version`、写好迁移文件,再安装一次。每次从 Gem Studio 安装都会自动得到一个更高的版本号,走的是和商店一样的升级流程:迁移在装好的那份数据上运行,失败就整个回滚。 ## SOP 怎么访问数据 SOP 只有 `ctx.call`,没有 `ctx.data`。 - **集合、Log、文档、Blob:调用你自己的工具**,由工具去读写。 - **`kv` 和 `rawSqlite` 可以直接调用**,不用声明。 ```js export default async function (input, ctx) { // 集合里的数据:交给自己的工具 const { id } = await ctx.call('tool.save_note', { title: input.title }) // kv:直接调用 const previous = await ctx.call('storage.kv.get', { key: 'last_note' }) // 没有时是 null await ctx.call('storage.kv.set', { key: 'last_note', value: id }) return { id, previous } } ``` | 调用目标 | 参数 | 返回 | 访问的存储 | | --- | --- | --- | --- | | `storage.kv.get` | `{ key }` | 值,没有时是 `null` | `kv` | | `storage.kv.set` | `{ key, value }` | `{ ok: true }` | `kv` | | `storage.sql.query` | `{ sql, params? }` | 行的数组 | `rawSqlite` | | `storage.sql.insert` | `{ table, row }` | `{ id }` | `rawSqlite` | - 调用目标和其他 `ctx.call` 一样在安装时扫描出来,见[事件与触发](/zh/dev/events.md#触发之后一次运行)。 - SOP 调用的工具在应用崩溃后可能被重跑一次(见[工具 API](/zh/dev/tools.md#一次调用是怎么运行的))。写数据的工具最好经得起重跑,比如用确定的 id 插入:重跑时得到 `E_DATA_CONFLICT`,而不是多出一条记录。 ## 其他存储种类 `ctx.data` 之外还有两种存储,每个 gem 都有,不用申请: | 种类 | 接口 | 适合 | 代价 | | --- | --- | --- | --- | | `kv` | `ctx.storage.kv.get(key)`、`.set(key, value)` | 小设置、同步游标 | 值只能是字符串,最多 1 MiB | | `rawSqlite` | `ctx.storage.sql.query(sql, params)`、`.insert(table, row)` | `ctx.data` 确实做不到的事 | 自己写 SQL,有时限和 256 MB 上限;没有修改记录、没有引用,平台不检查数据 | ### kv ```js const since = Number((await ctx.storage.kv.get('cursor')) ?? 0) // ……处理 since 之后的内容…… await ctx.storage.kv.set('cursor', String(cursor)) ``` - 存在 `data.db` 里(加密),每次 `set` 和别的写入一样留下修改记录。每个工具都有,不用声明。 - 值只能是字符串,要存对象请先 `JSON.stringify`。键 1–512 个字符,不能含控制字符。 - SOP 可以直接调用 `storage.kv.get`、`storage.kv.set`,见上一节。 ### rawSqlite ```js const { id } = await ctx.storage.sql.insert('notes', { title: input.title, created_at: Date.now() }) const rows = await ctx.storage.sql.query('SELECT id, title FROM notes WHERE created_at > ? LIMIT 50', [since]) ``` - 一个私有的 SQLite 数据库 `raw.db`,用 gem 的密钥加密,在单独的进程里运行。每个 gem 都有,不用声明。`query` 返回行的数组,`insert` 返回 `{ id }`(新行的 rowid)。 - **什么时候打开**:包里有 `db/schema.sql` 或 `db/migrations/` 的 gem,启动时就打开,表结构或迁移出错,gem 就启动不了,安装时就能发现。别的 gem 在第一次查询时才打开;从来不用的 gem,磁盘上没有这个文件。 - **表结构写在包里的 `db/` 文件夹**。在查询里 `CREATE TABLE` 也可以,但建议写进 `db/schema.sql`: - `db/schema.sql` 每次打开数据库都会运行,所以只能写可以重复执行的语句(`CREATE TABLE IF NOT EXISTS …`),改不了已有的表。 - 要改表结构,写 `db/migrations/<序号>_<名字>.sql`(比如 `0002_add_lang.sql`)。每个迁移只运行一次,在 `db/schema.sql` 之后按序号从小到大运行。序号不能重复;发布过的序号不要改、不要重用,修正也写成一个新的、更大的序号。文件名不是「数字_名字.sql」的,安装失败。 - 不能用 `ATTACH`、`DETACH`、`PRAGMA`(包括 `pragma_*` 函数)和 `VACUUM`,含有它们的语句会被拒绝。 - 代价:平台不知道这里面存的是什么——没有修改记录、不知道是谁写的、不能引用,也不检查数据。只在 `ctx.data` 确实做不到时用。时限和大小见[上限](#上限)。 ## 加密与隔离 gem 的数据都在它自己的数据文件夹里(组织数据文件夹下的 `gems/store/<gem id>/`): | 内容 | 存放 | 加密 | | --- | --- | --- | | 集合、Log、`kv`、修改记录 | `data.db` | 加密 | | Blob | `blobs/` | 加密 | | `rawSqlite` | `raw.db` | 加密 | | 迁移前留下的副本 | `data.db.bak-…` | 加密 | | 文档 | `docs/<id>.md` | **不加密** | | 定时、事件触发的 SOP 的[运行文件夹](/zh/dev/permissions.md#运行文件夹) | `runs/` | **不加密** | - **加密用的是每个 gem 自己的密钥**:随机生成的 256 位密钥,存放在应用的凭据库里,gem 的代码拿不到。只拿到这些文件、没有这把密钥,读不出加密部分的内容。 - **别的 gem 读不到。** gem 只能通过 `ctx.data` 和 `ctx.storage` 访问自己的数据,没有读别的 gem 数据的接口,别的 gem 的节点 id 会被拒绝(`E_PERM_DENIED`)。沙盒里的代码也不能直接读写数据文件夹里的文件。 - **每个 gem 的代码在自己的沙盒进程里运行**,不和别的 gem 共用进程。 - **卸载时全部删除**:整个数据文件夹(包括明文的文档和运行文件夹)和这把密钥一起删除。重新安装得到的是一份新的空数据。 - **删除是软删除。** `delete` 之后,记录和文档在接口里消失,但内容仍在数据文件夹里(文档的 `.md` 文件也还在),卸载时才一起删除;修改记录里保存着改动前后的内容,按 `eventRetentionDays` 清理。所以不要向用户承诺「删除就从磁盘上抹掉」。 - Gem Studio 的测试数据是单独的一份(见[在 Gem Studio 里测试](#在-gem-studio-里测试)),删除草稿时连同密钥一起删除。 ### 按组织隔离 用户可以在本机建立多个[组织](/zh/guide/organizations.md),每个组织是一套完全独立的数据。gem 按组织安装:同一个 gem 装在两个组织里,就是两份互不相通的安装。 - **各组织独立的内容**:gem 自己的数据(`ctx.data`、`ctx.storage`)、下载的资源文件(清单里的 [`resources`](/zh/dev/manifest.md#resources))、凭据连接的授权、知识库授权,以及写进知识库的内容。 - **看不到其他组织**:gem 运行时只能访问当前组织的数据,无法访问其他组织,也无法得知其他组织是否存在。`ctx.knowledge` 等接口不接收、也不返回组织 id。 - **切换组织会重启应用**:正在执行的工具调用会中断。未完成的 SOP 运行和到期的定时任务会在这个组织下次打开时继续。工具不要依赖进程常驻,需要保留的中间状态请写进数据里。 - **删除组织**:该组织内的 gem 数据随之永久删除。 ## 上限 ### ctx.data 的上限 | 项 | 上限 | | --- | --- | | 一条记录(JSON) | 1 MiB | | 一条 Log 条目 | 1 MiB | | 一篇文档 | 2 MiB;标题 500 个字符 | | 一个 Blob | 20 MiB | | 一次查询(表或 Log)的结果 | 1000 条、16 MB | | 一次调用扫过的数据 | 翻页查询约 64 MB,计数 100 万条 | | 一条查询的条件 / 排序字段 | 16 个 / 8 个;`in` 最多一个、最多 30 个值 | | 一次事务 | 500 个写操作;冲突时默认最多运行 5 次 | | 集合 | 一个 gem 64 个;每个最多 128 个字段、16 个索引(每个 1–8 个字段) | | `enum` | 256 个值 | | 集合名、字段名 | 64 个字符 | | 记录和文档的 id | 1–128 个字符,只含字母、数字、`_`、`-` | | 字段值的嵌套深度(`object`、`any`、不写 `items` 的数组) | 32 层 | | 引用的 `kind` | 匹配 `^[a-z][a-z0-9_.:-]{0,63}$`,不能以 `field:` 开头 | | 迁移 | 每批 60 秒,总共 10 分钟 | | `kv` | 键 1–512 个字符、不含控制字符;值是字符串,最多 1 MiB | | 整个数据文件夹 | 不设上限。超过 4 GiB 时在应用日志里记一条警告,之后每翻一倍(8 GiB、16 GiB……)再记一次,不会阻止写入 | ### rawSqlite 的上限 - `raw.db` 最多 256 MB。 - 一次查询最多返回 10 万行、16 MB,多了请加 `LIMIT`。 - 每次调用最多 10 秒;打开数据库时,`db/schema.sql` 和每个迁移各最多 30 秒。超时的语句会被停掉,调用报错;`db/schema.sql` 超时,这个 gem 这次就启动不了。所以不要写跑不完的查询(没有终止条件的递归 `WITH`、互相触发的触发器)。 ## 错误码 `ctx.data` 和 `ctx.storage.kv` 的错误消息以错误码开头,工具代码可以据此分支: ```js try { await notes.update(id, patch, { ifVersion: note.version }) } catch (err) { if (!err.message.startsWith('E_DATA_CONFLICT')) throw err // 别人先改了:重新读一遍再决定 } ``` | 错误码 | 含义 | | --- | --- | | `E_INPUT_INVALID` | 参数或记录不合格:类型不对、缺必填字段、没有声明的字段或集合、查询的写法不对 | | `E_DATA_CONFLICT` | `ifVersion` 不符;带 `ifVersion` 删除一条不存在的记录;插入的记录 id 或新建的文档 id 已经存在;事务读过的记录被改了 | | `E_DATA_NOT_FOUND` | 要改的记录、文档或块不存在 | | `E_DATA_UNIQUE` | 违反 `unique` | | `E_DATA_NEEDS_INDEX` | 大集合上没有用得上的索引,或者一次调用扫过的数据太多 | | `E_PAYLOAD_TOO_LARGE` | 超过大小上限:记录、Log 条目、文档、Blob、`kv` 的值、一次查询的结果 | | `E_PERM_DENIED` | 用了别的 gem 的节点 id | | `E_DATA_SCHEMA` | 版本倒退、同一版本里做了需要升版本的改动、改了集合的 `kind`、缺迁移函数或迁移失败 | | `E_DATA_CLOSED` | gem 已停用或卸载 | | `E_PERM_INVALID` | `data` 段写得不对,打包、安装和 Gem Studio 测试时报。消息以 `data:` 开头;打包和安装时,形状上的错误以 `notochord.json failed schema validation:` 开头 | | `E_ACTIVATION_FAILED` | 升级或同版本重装后 gem 启动失败,原因(比如 `E_DATA_SCHEMA`、`E_DATA_UNIQUE`)包在消息里;升级失败时整个回滚到旧版本。第一次安装时报的是原来的错误 | `ctx.storage.sql` 的错误不带这些错误码。
Notochord
简体中文English