Grist 数据库迁移与 Schema 变更完整指南:两库三 Schema 的演进体系
【免费下载链接】grist-coreGrist is the evolution of spreadsheets.项目地址: https://gitcode.com/GitHub_Trending/gr/grist-core
本文以 documentation/migrations.md 为核心骨架,深入讲解 Grist 的数据库迁移体系:文档数据库(
_grist_*与_gristsys_*两套表)与 Home 数据库分别由数据引擎(Python)、Node 服务器(TypeScript)和 TypeORM 三套代码各自管理、各自演进。读完本文,你将掌握:何时需要递增SCHEMA_VERSION、如何编写一次@migration装饰的 Python 迁移函数、如何在DocStorage.ts中追加基于原生 SQLite 的存储迁移、以及 Home 数据库 TypeORM 迁移文件的生成与命名规范——并能理解"只增不改、标记弃用"这一迁移哲学背后的跨版本兼容论证。
一、总览:两类数据库与三套 Schema
Grist 使用两类数据库,各自存储不同的数据,详见 documentation/database.md:
| 数据库 | 职责 |
|---|---|
| Home 数据库 | 存储整个 Grist 实例的数据:用户与组、计费、组织(团队站点)、工作区、文档元数据(ID、名称、所在工作区)、组织/工作区/文档级别的 ACL 权限等 |
| 文档数据库 | 每个.grist文档就是一个 SQLite 数据库,存储表格、页面、视图数据,以及文档内部的行/列级 ACL |
在代码层面,这两类数据库由三套数据库 Schema分别管辖,每套 Schema 都有自己的变更与迁移方式:
| Schema 位置 | 语言/框架 | 管理对象 |
|---|---|---|
| app/gen-server | TypeORM(TypeScript) | Home 数据库 |
| sandbox/grist/schema.py | Python | 文档数据库中的_grist_*元数据表 |
| app/server/lib/DocStorage.ts | TypeScript | 文档数据库中的_gristsys_*系统表 |
其中_grist_*表由数据引擎(Python sandbox)管理,_gristsys_*表由 Node.js 进程管理。这种"一个文档、两种管理者"的划分,决定了文档数据库内部的迁移也必须分两套机制进行。
二、文档数据库的_grist_*表:数据引擎的 Schema 迁移
2.1 修改 Schema 的前提:递增SCHEMA_VERSION
当你修改 Grist 的元数据表 Schema(即 sandbox/grist/schema.py 中定义的_grist_*表结构)时,必须做两件事:
- 递增文件顶部的
SCHEMA_VERSION。当前仓库中该值为46(见 sandbox/grist/schema.py)。 - 创建一条迁移:迁移是一组 DocAction(文档动作),作用于"上一版本"的文档,使其满足新 Schema。
schema.py顶部有明确警告:"Before changing this file, please review: /documentation/migrations.md",即本文档正是修改该文件的必读前置材料。
schemaVersion本身也作为元数据存储:_grist_DocInfo表中有schemaVersion列(类型Int),注释说明"Version number of the document. It tells us how to migrate it to reach SCHEMA_VERSION"(见 sandbox/grist/schema.py)。迁移的本质就是读取文档当前版本号,逐版本补齐到最新。
2.2 TypeScript 侧 Schema 的同步
TypeScript 在 app/common/schema.ts 中维护着一份 Python Schema 的拷贝,只要 Python Schema 变化,它就必须同步更新。该文件由core/sandbox/gen_js_schema.py自动生成(文件头部注明 "THIS FILE IS AUTO-GENERATED"),其SCHEMA_VERSION同样为46,与 Python 侧保持一致。这意味着 Schema 变更不是单点修改,而是"改 Python 源 → 重新生成 TS 拷贝"的联动流程。
2.3 编写迁移函数:@migration装饰器
新增迁移就是在 sandbox/grist/migrations.py 中添加一个以新版本号装饰的函数,形式如下(文档给出的官方模板):
@migration(schema_version=11) def migration11(tdset): return tdset.apply_doc_actions([ add_column('_grist_Views_section', 'embedId', 'Text'), ])这个示例在仓库中真实存在(见 sandbox/grist/migrations.py)。migrations.py提供了一些辅助函数让迁移更简洁:
add_column(table_id, col_id, col_type, ...):AddColumn动作的简写(L130-L132);maybe_add_column(...):仅在列尚不存在时才添加(L135-L138);next_id(tdset, table_id):取某表下一个可用行 ID(L141-L143);safe_parse(json_str):解析 JSON,非法时返回空对象(L146-L150)。
@migration装饰器(L112-L127)会把迁移函数注册进全局的all_migrations字典,键为版本号。它还支持need_all_tables=True参数:迁移会先用"仅元数据表"的方式尝试执行,若某个迁移函数被标记为需要全表数据,则失败后用全表重试。文档明确建议:新迁移不应设置need_all_tables=True,因为它要求对包含 on-demand 表在内的大文档做更多处理,代价更高。
2.4 迁移的执行机制:create_migrations
真正编排迁移执行的是create_migrations(L47-L104),其核心流程为:
- 从
_grist_DocInfo.schemaVersion读取文档当前版本(读不到则视为 0); - 构造一个
TableDataSet(来自table_data_set),用当前 Schema 的定义重建旧文档中的各表,并对旧文档中当前 Schema 已不认识的(即已弃用的)列使用不完整的默认列定义(见 L74-L87); - 将原始数据以
BulkAddRecord动作灌入TableDataSet; - 从
doc_version + 1逐版本执行到SCHEMA_VERSION(见 L93-L97),缺失的版本使用noop_migration空实现兜底; - 无论是否真的做了迁移,最后总是追加一条
UpdateRecord('_grist_DocInfo', 1, {'schemaVersion': SCHEMA_VERSION}),把文档版本号刷新到最新(L101-L103)。
注意:若文档版本高于当前SCHEMA_VERSION(降级场景),唯一的动作就是上面这条版本号更新,见 L99-L100 的注释。
2.5 数据搬运型迁移:不止是加列
许多迁移需要在加列的同时搬运或改写数据。仓库中有大量这样的例子,例如:
migration14(L559-L598):一次性创建整套 ACL 表(_grist_ACLMemberships、_grist_ACLPrincipals、_grist_ACLResources、_grist_ACLRules),并BulkAddRecord初始化四个默认组、默认资源与默认规则(permissions: 0x3F,即 63,对应 OWNER 全权限);migration15(L600-L624):为_grist_Views_section_field增加filter列后,读取所有 section 的filterSpec,把每个字段的过滤配置从 section 迁移到字段上,同时宣告filterSpec弃用;migration16(L626-L648):新增visibleCol列后,遍历所有列与视图字段,根据Ref:类型与widgetOptions反推并回填visibleCol的引用值。
这类迁移展示了标准姿势:先用tdset.all_tables[...]读取旧数据,构造新动作序列,再tdset.apply_doc_actions统一应用。
2.6 迁移的冒烟验证
migrations.py文件头部的哲学注释中(L30-L36)给出了每次迁移后建议执行的验证命令:
# 用真实样本文档验证升级路径 ./test/upgradeDocument public_samples/*.grist # 跑回归测试(更新回归基准数据) UPDATE_REGRESSION_DATA=1 GREP_TESTS=DocRegressionTests ./test/testrun.sh server # 再验证一个内置 fixture 文档 ./test/upgradeDocument core/test/fixtures/docs/Hello.grist若在 gvisor 沙箱下执行有困难,可在命令前加GRIST_SANDBOX_FLAVOR=unsandboxed前缀绕过沙箱(需注意这会让文档在无沙箱环境下运行,务必谨慎)。
三、文档数据库的_gristsys_*表:Node 服务器的存储迁移
_gristsys_*系统表的 Schema 与迁移都定义在 app/server/lib/DocStorage.ts 中。这些表由 Node.js 服务器管理,而非数据引擎——包括_gristsys_Action、_gristsys_Action_step、_gristsys_ActionHistory、_gristsys_ActionHistoryBranch、_gristsys_FileInfo、_gristsys_PluginData等(见 L84-L143 的建表 SQL)。
3.1 两套版本号:Storage Version 与 Schema Version
DocStorage.ts的注释(L76-L80)澄清了两个易混淆的概念:
- Storage Version(存储版本):即
docStorageSchema.migrations数组的长度,跟踪磁盘存储方式以及非数据引擎表(_gristsys_*)的 Schema 变化; - Schema Version(Schema 版本):即数据引擎元数据的版本(上一节的
SCHEMA_VERSION)。
在 SQLite 中,存储版本号通过PRAGMA user_version持久化保存。
3.2 迁移实现:原始 SQLite 查询函数
与 Python 侧的"动作序列"不同,_gristsys_*的迁移是直接对数据库执行原生 SQLite 查询的函数数组。以 v1→v2 的迁移为例(L150-L169),它把所有表的列类型统一改为 BLOB,采用了标准的重建手法:
CREATE TABLE <临时表> (<按新定义生成列>); INSERT INTO <临时表> SELECT <所有列> FROM <旧表>; DROP TABLE <旧表>; ALTER TABLE <临时表> RENAME TO <旧表>;后续版本依次处理:v3 将旧_gristsys_Action*表转换为_gristsys_ActionHistory*(L220-L234),以及补建_gristsys_Files表、为_gristsys_Action补linkId列(L192-L199)等历史演进。由于这些迁移直接面对磁盘布局,涉及 DDL/DML 级别的 SQL 操作,因此对 SQLite 方言的细节(如quoteIdent标识符转义、PRAGMA table_info反射)要求更高。
四、迁移哲学:为什么要用"笨"实现加载旧文档
文档明确指出:迁移很棘手(Migrations are tricky)。平时我们思考的是"正在编写的软件",但迁移面对的却是由旧版本软件创建的文档——它们可能不具备你假设的新逻辑,也可能具备当前版本完全不知道的旧逻辑。
这正是迁移代码必须使用"笨"实现(dumb implementation)来加载与检查数据的原因(参见 sandbox/grist/table_data_set.py):用主代码库直接加载旧文档通常会失败,因为旧文档不满足当前代码的种种隐含假设。TableDataSet提供的是最朴素的"表 + 行 + 列值"容器,配合create_migrations中"对未知列使用不完整默认定义"的策略,才能容忍旧文档的任意历史形态。
五、限制与规则:保证跨版本共享的可能
文档给出了迁移必须遵守的硬性规则,这是整份规范的核心约束:
WARNING: Do not remove, modify, or rename metadata tables or columns.(不要删除、修改或重命名元数据表/列。)
5.1 弃用而非删除:注释约定
旧列与旧表应当用注释标记为弃用,而不是删除。注释格式为:
# <columnName> is deprecated as of version XX. Do not remove or reuse.仓库中大量真实注释遵循此约定,例如_grist_DocInfo.docId、peers(sandbox/grist/schema.py)、_grist_Imports整表(L102)等。标记弃用有两个目的:防止未来添加同名实体,或防止用不同含义复用同一列。目前仅靠注释 + 在代码中移除对该实体的引用,未来可能增加代码层面的标记机制来强制禁止使用。
5.2 为什么不能删:跨版本通信论证
文档用一个 A/B 双版本场景说明保留旧列的正当性:假设 A 在 v10、B 在 v11。若列foo存在于 v10 却在 v11 被删除,那么 A 可能发送引用foo的动作,而 B 的代码完全不认识foo,会判定这些动作非法。解决办法就是 B 必须仍然"知道"foo——所以我们不删除旧列。同样的论证适用于重命名列,以及修改列(例如改变类型)。
5.3 改变含义或类型:必须新建列
WARNING: If you change the meaning or type of a column, you have to create a new column with a new name.
如果你改变了某列的含义或类型,必须用新名字创建新列,并编写迁移从旧列填充新列,同时把旧列标记为弃用。这条规则的推理基础与上一节一致:列被"改造"后,旧版本发出的、针对旧语义的动作就会失效。
综合来看,这套规则的目标是:让不同版本的用户之间"勉强可能"共享文档(即使仍需要更多工作),并让"升级后用旧版本打开文档"这件事相对安全。
六、Home 数据库:TypeORM 迁移
Home 数据库的迁移由TypeORM负责。迁移文件位于 app/gen-server/migration,在启动时自动执行——因此部署者无需手动运行任何迁移命令。
6.1 迁移文件形态与命名
该目录下共有 50+ 个迁移文件,均以毫秒级 Epoch 时间戳 + 语义化名称命名,例如1536634251710-Initial.ts、1551805156919-LoginDisplayEmail.ts、1759256005608-Proposals.ts、1764872085347-OAuthClientsAndGrants.ts等。从命名即可看出 Home 数据库的演进足迹:登录、计费、别名、团队、文档回收站、共享、配置、服务账号、提案(Proposals)、OAuth/OIDC 授权等能力都是通过逐个迁移叠加出来的。
6.2 生成新迁移
目录中的 README.md 给出了标准的生成流程:
# 在 app/gen-server/migration 目录下执行 npx typeorm migration:create MigrationName生成后,需要把新迁移登记到 test/gen-server/migrations.ts,确保测试环境也能识别。
6.3 rebase 后的重命名约定
由于迁移文件以时间戳为前缀,而 rebase 可能打乱顺序,README 还约定:正在开发的迁移必须是"最新"的那一个。获取当前毫秒级时间戳的命令:
date +%s%N | cut -b1-13拿到新时间戳后,将其作为你开发中的迁移文件前缀,即可保证它排在其他迁移之后。
七、总结:三套机制的适用边界速查
| 场景 | 修改位置 | 迁移机制 |
|---|---|---|
修改_grist_*元数据表(数据引擎) | sandbox/grist/schema.py + 同步 app/common/schema.ts | 递增SCHEMA_VERSION,在 sandbox/grist/migrations.py 新增@migration函数,返回 DocAction 序列 |
修改_gristsys_*系统表(Node 进程) | app/server/lib/DocStorage.ts | 向docStorageSchema.migrations数组追加函数,执行原生 SQLite 查询(版本号 = 数组长度,存于PRAGMA user_version) |
| 修改 Home 数据库 | app/gen-server/migration | TypeORM 迁移文件,启动时自动运行 |
无论走哪条通道,都要遵守共同铁律:不删、不改、不重命名元数据表/列;改变语义必须新建列;旧实体用# <name> is deprecated as of version XX. Do not remove or reuse.注释标记弃用。这套纪律让 Grist 的文档可以在不同版本的用户之间流转,也让"升级后回退"成为可能——这正是 Grist 作为"电子表格的进化体"所依赖的数据兼容性根基。
【免费下载链接】grist-coreGrist is the evolution of spreadsheets.项目地址: https://gitcode.com/GitHub_Trending/gr/grist-core
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考