存储
这一页说明 Covel 把数据存在哪里、由谁负责、各处保证什么。读者是要改存储层、排查数据问题或部署服务的开发者。只想知道自己的存档在哪个文件夹的玩家,请看数据与升级。
Covel 不用一个大而全的存储适配器去覆盖所有环境。它按部署方式分成三种配置(profile):记录的数据格式只有一份,每种环境用适合自己的数据库。
SQLite 是默认。桌面客户端和自部署网页版都用它,普通玩家不用关心数据库。PostgreSQL 必须支持,但面向专业开发者和有特殊需求的用户,例如多进程部署,或者要接已有的数据库。SQLite 和 PostgreSQL 过同一套契约测试,对外的行为应当相同,不同就是缺陷。多进程部署的限制见 API 多实例能力边界。
会话的创建代次保存在已有的 metadata.sessionIncarnationNonce 中;同 ID 重建不能复用它。事件生产者使用它的公开哈希绑定受众,订阅不逐帧读会话记录。隐藏插件数据和空批次不为事件读取会话;公开写入在写前捕获一次代次,UI 事件监听器只在代次变化时复核。跨进程删除、失联和重建的规则见协议中的会话隔离。
| 配置 | 谁是数据的最终依据 | 执行回合时用的存储 | 适用部署 |
|---|---|---|---|
browser-private | 玩家浏览器 IndexedDB 里的 Dexie BrowserVault | 服务端临时的 MemoryStore | 为以后的官方网页版准备,现在还不完整(官方网页版还不支持)。存档以玩家浏览器里的为准,不写进服务器的数据库 |
desktop | 服务端 SqliteStore | 同一个 SQLite | 默认。桌面客户端;自部署网页版(本地 SQLite 文件)。普通玩家都用它 |
cloud | 服务端 PgStore | 同一个 PostgreSQL | 专业开发者或有特殊需求的用户选用 PostgreSQL(例如多进程部署、要接已有的数据库)。桌面客户端和自部署网页版都可以选 |
商业版(多租户账号、配额)还没有做,这个仓库也不为它更新。
各类数据的契约和实现:
| 类别 | 契约 | 实现 |
|---|---|---|
| 服务端业务记录 | DataStore | 内存、SQLite、PostgreSQL |
| 浏览器私有的业务记录 | BrowserCheckpoint / SessionCommit | Dexie BrowserVault |
| 二进制资源 | MediaStore | 内存、SQLite、PostgreSQL、显式选择的浏览器 IDB 缓存 |
| 前端界面数据和缓存 | 应用 KV 和媒体缓存 | 轻量的原生 IndexedDB |
| 偏好和凭据 | 设置 / 配置契约 | localStorage、Electron IPC、桌面和服务端配置文件 |
浏览器端不再把服务端的增删改查和事务方法重新实现一遍,也没有 IdbStore。@covel/store 负责领域记录和检查点(checkpoint)的契约,apps/web 负责浏览器里怎么持久化。
这里的“游戏状态”是会话、角色、插件数据等当前记录的统称,不是另一套内核状态表。state_schemas、state_entries 和 state_changes 已移除;玩法数据放在插件自己的 namespace,共享领域对象通过 World Model 的提案写入。state_snapshots 仍保存整局的物化快照,与被移除的状态表不同;会话视图的字段见 API。
Server Backend Selection
STORE_BACKEND 只决定服务端的 DataStore:
| 值 | 用途 | 说明 |
|---|---|---|
memory | 测试、浏览器私有模式下的回合执行 | 只在进程内,重启即丢失。健康检查报告 frontendMode: "local"。 |
sqlite | 桌面客户端、自部署网页版、本地开发 | 文件路径取 SQLITE_PATH,相对路径以服务端工作目录为准。驱动是 Node 内置的 node:sqlite。 |
pg | 专业开发者或有特殊需求的用户;多进程部署 | 必须设置 DATABASE_URL;会话锁用 PostgreSQL advisory lock。 |
源码开发的路径选择见环境变量。pnpm dev 下服务端工作目录是 apps/server/,没有路径配置且 Covel 主目录不存在时,数据库在 apps/server/data/covel.db。
没有 STORE_BACKEND=idb,也没有 createStore({ backend: "idb" })。写了不认识的值(比如把 pg 写成 postgres),服务会拒绝启动,错误信息里列出可用的值。MEDIA_BACKEND 和 VECTOR_BACKEND 也是这样。
一个 SQLite 文件只归一个服务进程。
createStoreFromEnv()会独占创建<SQLITE_PATH>.lock,里面记着持有者的 pid 和启动时间。存储关闭或进程退出时删除它。- 第二个进程(比如桌面版和开发服务器用了同一个 Covel 主目录)启动时会失败,错误信息指出这个文件和持有者的 pid。
- 锁里的 pid 已经不在运行,这把锁就是过期的。接管时先把它重命名,再独占创建新锁,读回校验 pid 和启动时间后才算成功。
- 检查、接管和创建由临时的
.lock.claim目录串行保护。目录以非空状态原子发布,持有者崩溃后只移动它那一代的唯一标记;迟到的竞争者不能删除新持有者的标记。启动中的另一个进程会收到重试提示。 - 同一个进程把同一个文件打开两次不算冲突;相对路径和绝对路径共用引用计数,最后一次关闭才释放锁。
- 直接用
createStore()打开数据库文件的代码(测试、工具、只读脚本)不加锁。内存和pg后端也不加锁。
SQL 后端的代码布局。 两个后端各占一个目录:packages/store/src/sqlite/ 和 packages/store/src/postgres/,拆分方式相同。
*-store.ts(sqlite-store.ts、pg-store.ts)是一个很薄的工厂,把各模块拼成DataStore。schema.ts定义表,*-schema-ddl.ts建表。- 共享的
common/sql-*-records.ts查询层在数据库行和记录之间转换。 - 查询按功能面各占一个文件:会话、运行时记录、快照、状态、世界、插件数据、导出、生命周期、服务端设置。
新增一张表或一条查询时,放进对应功能面的模块,两个后端都要加,不要放进工厂。
媒体后端独立选择。 MEDIA_BACKEND 与数据后端无关。mirror 表示跟随当前的服务端数据后端。媒体后端是 pg(直接选或者跟随)而没有 DATABASE_URL 时,服务拒绝启动,不会悄悄关掉媒体;想不要媒体,设 MEDIA_BACKEND=none。网页端的媒体显示缓存在浏览器里,不是 MediaStore 后端。
PostgreSQL 不等于自动故障转移。 PostgreSQL 能在多个进程之间协调数据库写入、会话锁和 EventBus 的事件分发。但回合进行中的“引导”和“取消”仍然依赖进程内的执行句柄,请求必须到达正在执行这个回合的那个实例。旧式后台任务和持久化的分阶段任务在重启后的保证也不同。这些限制记在 API 多实例能力边界。
世界设定不做进程级缓存。 世界设定和记忆块的 schema 每次都从当前操作的 DataStore 读取。已提交的修改、同 ID 的替换,在下一次读取时就能看到;互相独立的 store 不会意外共用世界记录。运行时设置则在每次操作开始时取一份,之后的修改不会影响正在进行的那次操作。
写入顺序。 每个会话有一个写入计数器(session_write_counters)。每存一条回合消息、每次插入、修改或删除一条设定集条目、角色或插件数据,都从计数器取下一个号。号码在写入它的那条语句里取,计数器的这一行一直锁到事务结束,所以同一个会话的号码按保存顺序排列:看到号码 N 时,比 N 小的号码都已经看得到。这件事由数据库触发器完成(packages/store/src/common/write-order-ddl.ts),每条写入路径都算在内,内存后端用同样的规则。
- 回合消息把号码存在
write_seq。增量读取(listTurnMessagesAfter、插件的readTurnMessages)按它前进,而不是按createdAt:后台执行在运行时就取了时间,可能在之后的回合保存完才保存,按时间的游标会永远跳过它。时间线本身仍按时间排序。 - 设定集条目、角色和插件数据的最近一次变化存在
record_changes,每条记录一行,删除也留一行(墓碑)。listRecordChanges按号码读出来;这张表随会话里出现过的记录增长,不随写入次数增长。 - 删除会话时,删掉这些记录的触发器又会写入变化,所以注册表把
record_changes和session_write_counters放在最后删。 - 这一列和两张表从数据 schema 版本 5 开始有。版本 4 的 SQLite 或 PostgreSQL 数据库在启动时就被拒绝打开(
StoreSchemaVersionError),旧库不会被改动;先备份,再换一个新的数据库,见启动时检查数据 schema 版本。
归档向量的写入。 归档索引不再每回合读取全部角色、设定集和插件数据。每次写入任务从上次的位置读变化,逐条读取变化指向的那条记录:记录还在就嵌入它,记录不在、被停用或没有文字就删掉它的向量。向量里已经是同样的文字时不再调用 embedding。
- 读取失败或 embedding 返回空:本轮不越过这一条,已有的向量和进度保留,并发出一条带会话信息的警告;下一轮重试。
- 一个插件命名空间新变成可检索(
contributes.data.<ns>.search)时,单独补读它在当前位置之前的变化;不再可检索时,删掉它出现过的每条记录的向量,删完之前不写入新的向量。
Memory index ownership
MemorySystem 自己管理它的后台任务。宿主要关闭它的 store 之前,先停掉产生任务的一方,调用这个实例的 drain(),再查看 pendingTaskCount()。互相独立的实例不共用进程级的排空屏障。
查询时核对来源。 归档向量查询在返回内容前,只读取命中的那几条记录来核对,不读整个语料。命中的记录已被删除或已被修改时,退回关键词检索,直到异步的写入任务刷新了索引。通过 API 写设定集也会安排一次写入任务。删除一条归档来源只删它自己的向量,其余没变的向量保留,不再调用 embedding。
进度存在哪里。 召回游标(最后处理的 write_seq)和归档进度(最后处理的变化号码、已索引的可检索命名空间、未完成的补读或删除)存在 vector_index_progress 表里,和物理索引放在一起,不放在 plugin_data。
- 写进度时会比较上一次序列化的值,并在后端的写入边界内校验会话的“化身”(同一个 ID 被删除后重建,算不同的化身)。
- 删除或替换会话会同时清掉进度和向量;事务回滚会恢复进度。SQLite 向量扩展不可用时的例外见会话删除。
- 业务快照、分支(fork)和浏览器检查点都不复制它,所以它们会各自重建索引。
- 换向量模型时(玩家在会话里重建索引),
resetSessionVectorIndex在一个事务里删掉会话在旧模型表里的全部向量和全部进度,再把会话锁到新模型,或者解除锁定。它先比较会话当前锁定的模型和调用方看到的是否相同,不同就什么都不改。三个后端由同一套契约测试守住。 - 写进度时还带上这一轮嵌入用的模型(
expectedModelRegistryId)。会话已经换了模型,这一批整批被拒绝,旧模型算出的向量不会落进新索引。
IMPORTANT
这改变了开发期的存储契约。含有旧式“索引写在 plugin_data 里”的开发数据库和浏览器检查点需要重建;没有旧数据读取、迁移或过滤兼容。
包的导出。 @covel/store 的根入口只导出契约和懒加载的工厂。要直接用某个后端的构造函数,从 /memory、/sqlite、/postgres、/indexeddb 导入。只导入契约或工厂时,不会把所有原生数据库驱动都加载进来。
Server World and Session Deletion
删除世界由世界的 DELETE 接口负责编排,步骤如下:
- 在一把短暂的
world:<id>锁下,登记一个持久化的“删除租约”,然后释放这把锁。 - 逐个删除这个世界的存档,走的路径和会话 DELETE 完全一样:等写入方和后台工作结束、运行 SessionEnd、释放媒体引用、清掉会话的临时状态。
- 一个会话都不剩之后,才删除世界记录和生成的世界包。
底层的 DataStore.deleteWorld 只删世界记录本身。
SQLite 向量扩展不可用时。 即使数据库已有 vec0 虚表,也能删除会话。业务记录和索引进度仍在一个事务里删除,只跳过不可访问的向量表。扩展下次成功加载时,会在事务里清理所有模型表中已没有对应会话的向量行,保留仍存在的会话的向量;扩展恢复之前,这些孤儿行仍占用空间。
加锁顺序。
- 创建会话、分支、替换检查点:先拿会话锁,再拿世界锁,然后提交。
- 删除世界时,等待会话或运行钩子期间从不持有世界锁。
- 被标记为“正在删除”的世界,拒绝世界编辑、新建会话、分支和检查点替换。种子导入和文件重载也认这个标记,并在拿到锁之后重新读取磁盘内容。
- 所有服务端路径都用后端选定的
SessionLock,包括 PostgreSQL advisory lock。
失败和重试。 级联删除不是一个覆盖所有会话的大事务。
- 清理失败时,世界保留,并带一个可重试的标记;再发一次 DELETE 会继续剩下的工作。
- 过期的租约在十分钟后可以被别人接手。
- 客户端不能设置或清除保留字段
metadata.worldDeletion。 - 创建会话和分支时如果给了世界 ID,这个世界必须存在且没有在删除中。不带世界的会话仍然有效。
下面讲的浏览器 Web Locks 协调的是另一套由客户端自己拥有的工作区。远程模式下浏览器里的显示缓存仍由前端负责。
Browser-Private Protocol
浏览器私有模式下,玩家的数据以浏览器为准。服务端可以从请求头里读 API key 并执行一个回合,但不得把玩家的检查点或凭据持久化下来。
会话头
BrowserVault schema 6 在每个检查点旁边存一份很小的“会话头”:会话记录、修订号和提交时间。写检查点、应用提交、删除会话时,都在同一个 IndexedDB 事务里更新它。会话列表和锁的归属检查只读会话头,不用解码整段对话历史。更早的开发期 vault 需要重建,没有迁移,也不会退回去读旧的检查点行。
检查点里有什么
每做一个动作,检查点都要整份上传、整份下载。所以它只装“不随回合数增长”的东西,再加上对话本身:
- 全部游戏状态、消息和提示词历史:完整保留。
- 最近 40 次执行的回合结果和运行时输出(重试要指定一个最近的回合作为来源)。
- 每个运行时导出(runtime export)的最新修订。有后台任务没完成时,还要带上“最早那个任务的来源执行开始时”生效的修订,以及之后的每一个修订,因为任务读取的是那一刻的导出。
turn.started/turn.completed/turn.failed这几种 trace 行:执行状态要靠它们读出来。
工具调用日志、事件记录和其余的 trace 行只有调试页会读,所以留在产生它们的服务端工作区里。因此,私有会话的调试页不显示模型调用。
只在服务端的数据
下面这些不进检查点,只在服务端进程的内存里。服务端重启(包括托管平台的休眠和每次部署)后就没有了:
| 数据 | 重启之后 |
|---|---|
会话的服务端工作区(MemoryStore 镜像,以及判断重放和增量上传用的最近一份检查点) | 下一次动作重新上传检查点,存档不受影响 |
媒体字节(MEDIA_BACKEND 默认跟随 memory);检查点里只有 MediaRef | 丢失。浏览器的渲染缓存可能还有副本,但它不是权威来源 |
| 向量索引和它的进度 | 重新上传检查点时安排一次写入任务,重新调用 embedding 建立 |
| 工具调用日志、事件记录、其余的 trace 行 | 丢失;它们本来就只给调试页用 |
| 还没结束的持久化后台任务的进度和结果 | 丢失。任务结束后浏览器才额外请求一次检查点 |
| 交给后台任务的请求凭据(见安全边界) | 丢失;本来最多保留 5 分钟 |
服务端工作区没有闲置淘汰:每个打开过的会话都留在内存里,直到会话被删除或进程重启。玩家多的公开实例内存会随之增长。玩家看到的说明见在公开体验站上玩。
大小上限
上传的 JSON 最多 64 MiB(@covel/store/browser-sync 里的 BROWSER_CHECKPOINT_MAX_BYTES)。协议没有“部分上传”,超过上限的会话就无法继续。Web 客户端会测量每次上传的大小,达到上限的 80% 时提醒玩家(每个会话、每次页面加载只提醒一次),让玩家有机会在上传被 413 拒绝之前换一个新会话把故事接下去。
生成世界
生成世界之前,要先等存储能力检查成功,再决定存到哪里。浏览器私有模式用 return-only,把结果存进 BrowserVault。健康检查请求没完成或失败时,绝不会退回到服务端文件存储。生成的替换版本按 ID 更新世界列表里的那一项,不会多出第二张卡片。
一次动作的流程
一次动作在一把 Web Lock 下进行,锁的键是 vault 数据库名加会话 ID。同源的其他页面要等整个流程走完。IndexedDB 事务本身不会跨网络请求一直开着。
- 先恢复上一次遗留的待处理结果,再把这次动作里由浏览器产生的输入写进
BrowserVault。已经被后来的动作取代的排队动作不会写入新输入。 PUT /api/sessions/:id/browser-checkpoint用最新的完整检查点,在服务端填充一个临时的MemoryStore工作区。如果服务端已有一个校验过的工作区,而且里面有持久化的后台任务,它会保留这些任务拥有的状态,只接受新的玩家消息、会话状态和模型设置、以及有权限的世界编辑。响应里出现reconcileRequired时,要先下载一个hydrate:<revision>提交才能继续。不支持的检查点修改返回409。- 记下待处理的
actionId,然后对这个工作区执行普通的动作接口或 plugin-RPC 接口。 POST /api/sessions/:id/browser-commit把工作区的结果导出成带修订号校验的SessionCommit。- Dexie 在同一个事务里应用这个提交,并清掉待处理动作(只清这个
actionId的,后来记下的动作保留)。用同一个actionId重放不会产生任何效果。重放按服务端发来的检查点认出同一个提交:浏览器在写入前换上自己的世界,这个世界之后可能被编辑过。过期的修订号、修订号相同但内容不同的头,都会被拒绝。
持久化的时机。
- 客户端把检查点上传和提交下载排成一条队。
- SSE 消息立刻显示,但不逐条持久化。动作结束后的那个检查点是唯一的一次持久写入。
- 后台任务的终结事件只在持久化的 worker 结束之后才请求一次额外的检查点,这样后台工作不会丢。
- 上传的准入边界也会保留 worker 的结果,即使事件丢了,或者 worker 恰好在下载和上传之间完成。
- 提交下载失败时,待处理动作在页面刷新后仍然存在。必须先把它恢复,浏览器才被允许上传更旧的检查点。
本地修改走同一条边界。 本地的检查点修改和会话删除用同一套归属规则。新的本地输入不能让修订号跑到一个还没恢复的结果前面。页面关闭会释放它的 Web Lock,于是另一个页面可以恢复它留下的待处理结果。互相独立的会话不共用这把锁。远程模式(以服务端为准)仍然直接用服务端的协调。
开场阶段的“重试”和“跳过”也走这套工作区交换:先提交更新后的会话状态,再发布给界面;刷新后的恢复也是这样。
世界和会话的加锁顺序
世界的归属先于会话的归属。
LocalDataService的每个会话操作(包括创建和删除)先持有世界的共享 Web Lock,再持有它自己会话的独占锁。为构建检查点而读世界,也在这个边界之内。- 编辑世界、替换生成的世界、删除世界:持有世界的独占锁。为服务端准备世界:共享持有。
- 所以字段补丁总是合并到最新的世界上,会话检查点不会用旧副本覆盖一个刚被同时编辑过的世界。
- 不同会话因为只共享持有世界锁,仍然可以并行执行。
删除世界。 先等已经放行的会话操作结束,再列出会话,在会话锁下清理它们的镜像,最后删除本地的领域记录。排在删除后面的新建会话会重新检查世界,世界没了就失败。
规则。
- 拿锁的顺序永远是先世界后会话。工作区回调里不得再递归申请本地的世界锁或会话锁。
- vault 的底层写方法依赖这一层服务的归属,不会在已有的操作里再次加锁。
生成世界的修订保存。 修订保存是整条记录替换,不是字段补丁,也不是和旧文档合并。
- 修订保存还要附上“发模型请求之前”取到的那份世界。服务在独占锁内把这份基线和最新的公开世界形状比较(包括从 metadata 里显示出来的维度),忽略 JSON 对象的键顺序。
- 世界已变化或已不存在:拒绝这个过期的结果,不覆盖别人的编辑,也不让已删除的世界复活。
- 首次生成没有基线,要求在同一把锁内 ID 尚未被占用,所以不会覆盖生成期间另一个窗口创建的世界。
- 生成的内容和它的基线都在等待 I/O 之前先复制一份。
前提:Web Locks
浏览器私有模式需要 Web Locks(HTTPS 或 localhost,并且浏览器支持)。不支持时,在发出任何动作之前就报工作区错误,没有“每个标签页各管各的”这种不安全的退路。这套协调只覆盖同一个浏览器源、同一个 vault 的页面,不涉及别的浏览器或别的源。
检查点的范围和版本
BrowserCheckpoint 包含恢复一个会话所需的每个领域:会话和世界记录、消息和执行日志、事件和 trace、角色、插件数据、角色 schema、自有的设定集条目、交互、挂起、快照,以及生命周期账本。
只支持当前格式,信封是 schema v2。存储边界会拒绝:缺少会话时钟字段的、执行来源或状态不是规范值的、旧的快照载荷,以及 schema v1 的检查点。
Browser Databases
Web 应用用三个数据库,生命周期各自独立:
covel-browser-vault(Dexie schema v6):最新的会话检查点、精简的动作幂等记录、待处理的服务端提交,以及浏览器创建的世界和一个持久的初始化标记。covel-browser-cache(原生 IDB schema v2):界面状态、已提交的表单块、执行显示缓存、媒体元数据和渲染用的 blob。covel-browser-credentials(Dexie schema v1):一次性的会话所有者令牌,按会话 ID 存。各自独立的事务让并发的兄弟写入不会互相覆盖;比较“当时取到的令牌”可以防止迟到的创建或删除响应把新换的凭据冲掉。凭据不进缓存,也不进游戏或设置的导出。不支持旧的 localStorage 令牌表。
会话凭据的核对
每次尝试删除远程世界之后,客户端会带着当时取到的令牌去探测自己存的会话凭据,只删除服务端明确回复 session_not_found 的那些。
- 它不会因为“过滤后的会话列表里没有”就推断会话不存在,也不在凭据表里重复记录世界的归属。
- 这些探测共用一个三秒的网络期限。失败时,未核实的凭据和原来的删除结果都保留。
- 新创建的凭据先持久化,再做身份校验。这样,迟到的创建响应可以和已经完成的删除清理对上。
- 确认缺失或已被替换的创建结果判为失败;无法确定时保留持久化的凭据。
- 这不是一个跨网络的事务。
远程模式的界面缓存
远程界面缓存有独立的 remoteSessionUi 存储,记着会话化身和世界归属;remoteUiEpochs 里还有短期的操作纪元(epoch)。
- 缓存的读写都带着调用方的会话身份。
- 读取、新建或替换绑定时要查询服务端;在已核实的绑定下更新显示则只在本地进行。
- 所有操作都在一个 IndexedDB 事务里校验纪元和当前的缓存绑定。这样,迟到的响应不会覆盖一个同 ID 的替换。
- 并发的表单提交会合并。
- 本地和远程的时间线写入,在写入所在的同一个 IndexedDB 事务里按
(turnId, runtimeId)合并部分历史。空批次保留历史;会话或世界的生命周期删除会显式清空它。 - 匹配到的行采用新来的观测,包括从“挂起”变成“运行中”。仅凭状态无法在多个标签页之间定出先后;同一行的竞争观测没有共同的因果修订号,这不在本契约之内。
- 恢复历史时保留已持久化的推理内容、工具身份和中止原因。
读取的归属
会话恢复、订阅刷新、消息和右侧面板的数据填充,在每个 provider 实例内共用“进行中的资源”的归属。
- 会话 provider 拥有插件数据的初始值;右侧面板只加载本地化后的显示定义。
- 对同一资源的新读取会替换旧读取。
- 已提交的事件和快照发布会让重叠的待定观测失效。当前有效但被失效的读取会重新拉取;过时的访问直接停止,网络错误向上抛出,不做重试循环。
- 插件快照按命名空间整体替换,包括已删除的条目。
开始、恢复、重连、执行恢复和交互提交,都通过同一个归属边界发布游戏状态。
- 恢复期间收到的已提交状态或角色事件,会让它待定的快照失效;快照发布之后才到的事件,会安排一次后续恢复。这些信号不缓存重放。
- 后台任务的终结通知在会话和访问校验之后立刻触发浏览器检查点,早于任何界面恢复的缓冲。所以重启一次显示刷新不会丢掉必须做的持久化。
- 首次合并历史时,保留 ID 相同的现有消息。
重连时衔接历史
重连和执行恢复的观测会保留已加载的历史和它的“更早消息”游标。
- 如果最近的快照和已加载的持久消息没有重叠,就用现有的消息 API 往回翻页,直到两个窗口接上或到达历史起点;每页之间都检查访问和读取的归属。
- 衔接失败或没有进展时不发布:现有的连续窗口保持可见,显示一条错误,并以只读方式重试恢复。
- 终结态的叙事接管只清除匹配的那个回合和运行时的流式缓冲;更新的、健康的 POST 流仍然以它为准。
- 显式重载插件目录,和恢复、重连的读取共用同一套 provider 资源归属和访问代次检查。
会话状态从权威快照恢复。角色的增量更新直接改 reducer 状态。
远程删除
远程删除在服务端请求的前后都让受影响的会话或世界纪元失效,并用服务端的结果核对缓存的所有者,部分失败时也一样。
- 认证错误、网络错误或缺少身份标记:保留数据。
- 确认不存在,或出现了新的化身:允许删除。
- 缓存出错不会回滚服务端的删除。
- 这套协议不需要 Web Locks,也不保证离线的客户端立刻看到另一台设备做的删除。
本地模式和远程模式用不同的缓存存储,单凭会话 ID 不能在两者之间转移归属。远程模式只用当前按身份划分的 schema,不提供迁移、双读或退回旧显示格式。读取和绑定变更会多一次会话 GET,流式更新不会。不假设两种方式吞吐量相同。
示例世界
示例世界只在“新建的 vault 第一次初始化一个空库”时插入。
- 世界记录和初始化标记在同一个 IndexedDB 事务里提交,所以并发的标签页不会重复插入,写入失败也不会留下半套。
- 初始化失败可以由同一个服务重试。
- 把所有世界都删掉后,标记仍在,刷新后库保持为空。
- 只维护当前的 vault schema。旧的开发期 vault 在 schema 变更提交之前就被拒绝,必须显式重建后才能用。不迁移也不清除历史内容。
- 显式的整库重置会连同领域数据一起清掉这个标记。
表单和时间线
已提交的表单块 ID 和表单值在一个 IndexedDB 读写事务里合并。
- 并发的提交(包括来自不同标签页的)各自的块都会保留;之后对同一个块的写入替换这个块的值。
- 表单输入和时间线快照在异步存储之前先复制,调用方之后的修改不会改变已经请求的保存。
- 删除会话的“已提交块”记录,仍然是显式的重置方式。
资产进度和缓存生命周期
- 资产进度按资产(没有资产 ID 时按插件和 runtime)与阶段合并,最多保留 256 个回合、每回合 256 项。消息窗口移除一个回合的最后一条消息时,也清除该回合的资产和进度。
- LocalDataService 的表单和时间线写入持有世界和会话的归属,并在放行后检查会话仍然存在;输入在等待放行之前先复制。
- 排在删除后面的写入会失败,不会把缓存重新建出来。
- 会话删除(包括删除世界时的会话清理)要等表单和时间线清理完才释放归属。清理失败不会撤销领域删除,只走现有的开发期警告,可以再删一次来重试。
- 远程模式仍然使用浏览器的表单和时间线缓存,但没有这种本地 vault 的归属保证。跨设备的表单状态和远程缓存清理,不由这套机制提供。
- 两种模式都以事务方式合并不同的时间线行;同一行的竞争观测仍然没有共同的因果修订号。
应用 KV 和连接
- 应用 KV 的写入和删除以事务完成为准,不以单个请求成功为准。事务中止时,即使请求成功了也算失败。
- 应用 KV 和可选的渲染 blob 缓存,每个页面共用一个连接和一次进行中的打开操作。
- 打开失败的连接和意外关闭的句柄会被丢弃,之后的操作可以重连。
- 收到版本变更通知时,关闭并释放句柄,让删除或升级得以进行。它不删数据,也不自动重放失败的操作。
- 被阻塞的打开会直接失败,不会无限等待。原生请求仍被阻塞期间,后来的调用复用这次失败,不再排一个新的打开。之后被放弃的升级会被中止,或者关闭它的句柄。
- 出错时,应用 KV 报告错误;渲染缓存退回到已授权的网络响应。
渲染 blob 缓存
- 读取时校验存储的记录和预期的媒体形状,无效数据算未命中。
- 延后的淘汰会在事务里重新检查,所以不会删掉另一个标签页刚写入的有效替换。
- “是否第一次写”的判断和插入在同一个读写事务里,跨标签页也保留第一条记录。
- 写入和删除等待事务完成;中止时产生安全的诊断信息,不让媒体渲染流程失败。诊断信息不含原始的浏览器错误和签名 URL。
- 这个缓存只是优化,不是媒体的权威来源。按容量淘汰尚未实现。
只保留最新的检查点
只保留最新的一份完整检查点。快照历史本来就在检查点里面,每个动作都留一份完整检查点会让体积按平方增长。
- 精简的
commits表存修订和动作的元数据,以及一个定长的sha256:摘要。摘要对“键递归排序后的检查点 JSON”计算。 - 不支持历史上的 JSON 提交摘要。重放同一个动作 ID 时,内容变了仍然会被拒绝。
- 新提交的哈希在它的 IndexedDB 写事务开始之前算完。
- 唯一的版本变更处理器只做一件事:拒绝不支持的 vault。它不转换历史摘要,也不写初始化标记。
检查点里不许出现凭据
任何事务写入检查点之前,BrowserVault 会递归检查会话记录(包括 metadata),拒绝形似凭据的字段名,比如 apiKey、access/refresh token、密码、私钥、client secret。
- 只查会话记录,因为这部分是框架代码写的,所有者令牌或服务商密钥只可能从这里漏进去。
- 不查检查点的其他领域和世界记录。那里是作者、模型和插件写的游戏内容,字段名不受限制(角色可以有一个
password字段)。如果按名字拒绝,这个会话就永远存不了了。 - 服务商密钥仍然只通过请求头传递。
本次开发期的重新设计不导入已移除的 covel-browser 业务 schema。旧实现可以从 Git 历史里找回。
Desktop Paths
Electron 以远程模式使用 Web 界面,业务记录通过本地服务端的 SQLite 持久化:
~/.covel/
config.toml
llm.toml
keys.env
settings.json
plugins/
<data_root>/ # default ~/.covel/data
covel.db
worlds/
logs/
server.port[paths] data_root 会把 SQLite、日志、服务端端口状态和用户创作的世界一起搬走。配置和密钥仍留在 ~/.covel/ 下。Electron 的 WebView 可能还会创建 covel-browser-cache,但绝不在里面存权威的游戏记录。各项配置的写法见桌面配置。
Capabilities And Migrations
/api/health.storage 报告:
data:服务端后端、持久性和前端模式;media:配置的和实际生效的媒体后端;vector:配置的向量模式和具体驱动;migrations:服务端 schema,加上轻量的浏览器缓存和媒体 schema。
Dexie BrowserVault 的 schema 归 apps/web 所有,不作为服务端 DataStore 的迁移对外报告。VECTOR_BACKEND=embedded 使用当前服务端 store 的能力;BrowserVault 有意不实现向量检索。
启动时检查数据 schema 版本。 两个 SQL 后端共用一个数据 schema 版本:packages/store/src/migrations.ts 里的 DATA_SCHEMA_VERSION。Covel 不迁移旧数据,所以打开数据库时先比对版本,再执行建表语句:
- SQLite 把版本记在
PRAGMA user_version,PostgreSQL 记在单行表covel_schema_meta。 - 记录的版本和当前相同:正常打开。
- 记录的是别的版本,不管更旧还是更新:拒绝打开,抛出
StoreSchemaVersionError(code: "store_schema_incompatible")。错误的database字段和消息都指出被拒绝的数据库:SQLite 是文件的绝对路径,PostgreSQL 是主机、端口和库名,不含用户名或密码。拒绝发生在任何建表语句之前,旧库不会被改动。 - 没有记录版本(新库,或加入这项检查之前写的库):只检查已经存在的 Covel 表是否有当前 schema 的每一列。缺列就同样拒绝;缺整张表没关系,启动时会建。检查通过后写入当前版本。
- 桌面版把这个错误显示在启动页上,并给出数据目录和
config.toml的路径。
开发数据库的重置以错误里的实际路径为准。先停掉使用它的进程;数据不需要保留时,可删除该文件及其 -wal、-shm 文件后重启。需要保留时,把这些文件一起备份并移走,或把 SQLITE_PATH 指向一个新文件。PostgreSQL 则先备份,再把 DATABASE_URL 指向一个新的空库。
某张已有表的列发生了旧库跟不上的变化(增、删、改名、改类型)时,要把 DATA_SCHEMA_VERSION 加一。packages/store/tests/schema-version.test.ts 记录了每个版本对应的表结构摘要,表结构变了而摘要没更新,这个测试就会失败。
PostgreSQL 的启动建表串行执行。 版本检查、建表语句和写入版本放在同一个事务里,开头先拿一把固定键的事务级 advisory lock。几个服务同时启动、连着同一个库时(滚动发布、多个实例),它们依次建表,不会在系统目录上冲突。单独启动的 PostgreSQL 媒体存储建表时也拿同一把锁。vector_models 的触发器只在缺失时创建,日常启动不再锁这张表。
Prep Drafts and Session Lore
草稿。 按世界存放的应用 KV 覆盖层,是留给以后访问用的编辑草稿。
- 读取归属于当前的世界和来源文本,不会替换更新的编辑或重置。
- 空字符串是有效的草稿。
- 读写失败时显示重试按钮和安全的操作诊断,不记录设定文本和原始错误。
- 创建会话要等首次读取完成或玩家明确编辑过,但不依赖草稿写入成功。
设定怎么进会话。 准备页把它显示的设定文本直接交给会话创建。
- 本地模式存在检查点的会话 metadata 里;远程模式在服务端的会话事务里创建。
- 两种模式都在生命周期钩子之前把它交给首次的服务端镜像。
- 开始或恢复这个会话时,不再读世界草稿。
- 没有快照的已有会话继续用世界的设定,除非 API 调用方显式给出
start_session.loreOverride。 - 创建时的快照使用世界记录的设定 schema,所以已有的长文档仍然可用。显式的动作覆盖保持 500000 字符的上限。HTTP 请求大小限制仍然适用。见会话 API。
快照和分支。 物化的快照把可选的覆盖记为 session.loreOverride。
- 分支把这个值恢复到子会话的 metadata 里。检查点传输和多次分支都保留它,包括显式的空字符串。
- metadata 里只有这一个玩法字段会跟着走;每个子会话得到全新的归属和生命周期身份。
- 按当前契约,字段不存在表示使用世界的设定。
- 快照没有记录的覆盖,不能由仍在运行的父会话的 metadata 来提供。见快照与分支 API。
Record Identity
消息 ID。
addMessage是普通的 INSERT:在一个 DataStore 里,消息 ID 跨所有会话唯一。ID 重复时拒绝,不会替换原来的行。commitPlayerInputMessage是另一条幂等的“采纳输入”路径,它的语义不适用于普通插入。- 浏览器检查点校验会在打开替换事务之前,拒绝
checkpoint.messages里重复的 ID。同一个 ID 被别的记录领域使用仍然有效。
列表的完整顺序。 时间相同的行,每个后端都按 ID 的字节序排,所以 MemoryStore、SQLite 和 PostgreSQL 返回同样的顺序,limit 切到的也是同样的行:
listEvents、listSnapshots、listSuspensions、listExpiredSuspensions:(createdAt, id);listInteractionRecords:(timestamp, id),从新到旧;
显示消息顺序。 完整列表、游标分页和分支都按 createdAt 排列,同一时间戳内保持写入顺序。检查点数组保留这个并列顺序。分支筛选快照边界后沿用父会话的顺序,再分配新的消息 ID。浏览器分页按检查点数组里游标行的位置切页;游标的 ID 和时间戳没有匹配行时,只返回更早时间戳的消息。
更新不换行。 更新改的是一行里装的内容,不是换成另一行:
- 更新已有的插件数据行,ID 和创建时间不变;
- 世界和设定集条目保留创建时间;
- 重新保存的挂起(suspension)保留它的回合、运行时、插件和创建时间。
内存、SQLite 和 PostgreSQL 在这一点上一致,共享的契约测试会检查。
会话 metadata 的合并。 updateSession 把补丁里的 metadata 浅合并进已存的对象:每个键替换自己的值,值为 undefined 的键被删掉(null 保留),合并后为空就存成没有 metadata。会话不存在时什么也不做。
- 合并在每个后端都是原子的。PostgreSQL 在一条 UPDATE 里合并;SQLite 和内存在串行的写入边界里先读后写。所以两个并发更新改的是不同的键时,两边都会保留。
- 两个并发更新改同一个键,仍然只有一个生效。先读 metadata 再决定写什么的调用方,要持有会话锁。
共享的契约测试会检查这些。
挂起。 待处理的挂起保存继续执行所需的请求和控制状态:消息、工具调用与缓冲提案、冻结输入,以及 agent 的 loop 状态(已用纠正次数、发现的工具、引导前正文和完成工具输出)。恢复沿用这些状态,不从后来变化的会话重新组装;步数和时限按 runtime 清单为本次恢复重新取得,纠正次数则继续累计。function 的 ctx.recursiveCall 子结果随父挂起保存,恢复成功后才一起提交;这与只返回数据的子 agent 不同。完整规则见挂起与恢复。
- 恢复提交后,
markSuspensionResolved保留这一行(重复的恢复请求和会话的挂起列表还要找到它),并丢掉恢复已经用掉的内容。已解决的行只保留执行身份、语言和回合号(settledSuspensionContinuation)。 - 快照只复制待处理的挂起。所以已解决的挂起永远不会把提示词带进快照、分支、导出或浏览器检查点。
追加位置。 SQLite 查询回合结果的追加位置时,用覆盖索引 (session_id, created_at, seq)。启动时的 DDL 从 Drizzle schema 推导出这个索引。它保持现有的序号分配和排序;不限制执行产物的保留量,也不构成生产环境的延迟保证。PostgreSQL 也有同样的索引,供按写入顺序读回合结果时使用。
历史和快照的索引。 每回合都要读的几样东西,各有一个已经排好序的索引,长局不必每回合把整段历史排一次序:
- 回合消息按
(session_id, created_at, order, id)建索引:完整历史、末尾若干条和已压缩标记都按它读。 - 还没压缩的消息另有一个同样列的部分索引(
compacted_at_turn_id IS NULL)。每回合的提示词历史只读这一部分,所以读取量随未压缩的尾巴变化,不随整局长度变化。它仍然返回全部未压缩的内容,压缩预算决定这部分多长。 - 玩家消息数由
(session_id, source_type)索引直接计数。 - 快照按
(session_id, created_at, id)建索引;parent_id有一个部分索引(parent_id IS NOT NULL)。清理旧的自动快照是一条 DELETE:删掉比最新keep个更旧的,但保留任何会话里有分支指向的那些。
两个后端声明同样的索引(PostgreSQL 的名字多一个 pg_ 前缀),测试会比对;部分索引的条件写在 Drizzle schema 里,启动时的 DDL 一起生成。已有的数据库在下次启动时补上新索引。
文本清理。 所有后端在持久化之前都会去掉文本和 JSON 内容里的 U+0000,嵌套的值也一样。记录标识符里含 U+0000 会被拒绝;清理后会发生冲突的 JSON 键也会被拒绝。设定集的小数插入顺序会保留。文本的并列排序按字节序;时间戳相同的对话行保持插入顺序。
序号分配。
- PostgreSQL 用每张表各自的序列分配消息、trace 和回合产物的位置,多个连接同时写也一样。
- SQLite 在它串行化的写入边界内分配。
- 回滚后序号有空洞是正常的。按顺序导入时,按导入列表的顺序分配新的位置。游标分页和完整列表用相同的位置。
- 两个 SQL 后端的数据 schema 版本都是 8。启动时会检查它,见启动时检查数据 schema 版本。版本 8 移除了三张内核状态表,schema 7 的 SQLite 和 PostgreSQL 开发数据库及其快照必须重建。浏览器存档的版本不变。
向量检索。 先按会话、插件、命名空间过滤,再精确选出 top-K。内存、SQLite 和 PostgreSQL 都返回欧氏 L2 距离。PostgreSQL 会把过滤后的候选物化,而不是用全局的近似索引,因为后者在过滤之后可能漏掉命中。
检查点里没有诊断数据。 浏览器检查点不含 LLM 和钩子的诊断 trace 载荷,也不含重复的 trace 主题事件行。这些诊断以服务端为准。检查点仍然带着玩法状态和执行恢复状态;这不是增量检查点协议。
各类记录的身份。
- 世界维度在共享的记录边界处归一化。
- 角色的身份是
(sessionId, id)。 - 设定集条目的身份还包含它的所有者:世界、玩家,或某个具体的插件。插件只能修改自己的设定条目,即使别的所有者用了相同的条目 ID。
- 每个会话有一份权威的
characterSchema,角色类型是开放的,属性经过校验。 - 会话的座位存放在
sessions.seats:SQLite 是TEXT里的 JSON,PostgreSQL 是jsonb,都是 NOT NULL,列默认值是默认座位。每次写入和读取都用@covel/shared的seatsSchema校验,包括绑定约束(assertSeatsValid),违反时整条写入被拒绝。写入时整体替换,不做深合并。座位随快照(SnapshotSessionState.seats)、分叉和浏览器检查点保存。绑定的角色在读取时推导,所以新建玩家角色不写座位。
MemoryStore、SQLite、PostgreSQL 和浏览器检查点都保持这些身份。所以浏览器持久化和服务端共用领域形状,但不共用服务端的表结构或各后端的增删改查实现。
模型路由。 模型路由只看三样:slot 设置、请求覆盖、会话的 runtimeModelOverrides。会话不带 presetId,会话记录、检查点和快照里都没有这个字段。任意的会话 metadata 不能用来选模型。embedding 模型的身份和锁定时间,在创建和更新时都会持久化,三个后端一致。首次锁定和重建向量索引也会更新会话的 embeddingModelId 和 embeddingLockedAt;清除索引会清除这两个字段。
Current Snapshot Contract
重存同一个快照。 为同一个会话再次保存同一个快照 ID,会连同载荷和其他刷新的字段一起替换采集时间(createdAt)。完整读取、按时间排列的列表、最新优先的元数据分页,在内存、SQLite 和 PostgreSQL 上都使用刷新后的时间。快照 ID 和所属会话不变。事务回滚会同时恢复之前的载荷和采集时间。
载荷 schema v3。 以下字段必须存在:characterSchema(对象或 null)、runtimeExports、sessionSummaries、compactedMessageSummaryIds 和 displayMessagesBoundary。
- 空数组、空映射和值为 null 的聊天边界,都是明确采集到的值。
- 缺字段就是无效,包括版本号相同的旧开发期载荷。这些快照需要重建;不提供迁移,也不会退回去用父会话当前的状态。
- 内存、SQLite 和 PostgreSQL 在写入前校验快照载荷;SQL 读取和浏览器检查点校验用同一份 schema,包括已采集摘要的引用完整性。
- 快照构建器采集的是 JSON 序列化后的数据:省略值为 undefined 的属性,不改动 MemoryStore 里的实时记录。
- 检查点导出会校验已存储的分支归属,但不修复历史上按父会话划分的行。
旧的状态补丁缓存保留在 IndexedDB 中,但不再读取,也无需为此重建。BrowserVault、浏览器检查点、快照载荷和浏览器缓存的版本保持不变,依次仍为 6、2、3、2。
插件扩展迁移。 这次迁移移除了工作记忆表和角色的插件镜像。记忆块现在是普通的插件自有数据。受影响的开发会话、快照和浏览器检查点需要重建,旧数据不迁移。
WARNING
已有的 SQLite 数据库可能在启动时就失败:旧的 lorebook_entries 表用的是 plugin_id,而当前的表和索引需要 owner。CREATE TABLE IF NOT EXISTS 不会改这张表。处理办法:停掉服务,把数据库连同 -wal 和 -shm 文件一起备份,然后换一个新的 SQLITE_PATH,或者重建开发数据库。只在旧数据库里新建会话是不够的。步骤见开发数据迁移。
Server Settings
server_settings 是一张不属于任何会话的表:服务端自己要用到的每项设置占一行(key、value、updated_at)。通过 setServerSetting / deleteServerSetting 写,用 listServerSettings 读。删除会话不影响它;它也不进快照和浏览器检查点。每个后端都对它通过同一套契约测试。
值是 JSON 文本,在 SQLite 和 PostgreSQL 上都存在 text 列里,由共享的查询层编码。有意不用 jsonb:PostgreSQL 的驱动层会解析它从 jsonb 列读出的每个字符串,于是存进去的字符串 "30" 读出来会变成数字 30。
有哪些键、谁可以写、Web 应用怎么用,见 settings-store.md。
Retention Of Operational Records
执行过程中的记账数据每个回合都在增长。下表里的几类有上限;表后面列的那些,会话存在多久就保留多久。
| 记录 | 上限 |
|---|---|
kind=auto 快照 | 每个 completedPlayerTurns 值一份(同一计数下后来的提交会刷新它)。保留最新的 COVEL_AUTO_SNAPSHOT_RETENTION 份(默认 20),被某个分支记为 parentId 的快照除外。手动快照和分支快照从不清理。 |
| 快照载荷和分支 | 不含控制面命名空间 _runtime_jobs、_runtime_job_control 和 _logs。对于排除规则生效之前拍的快照,分支时也会把它们去掉。 |
_runtime_jobs 和它们的 job_status 行 | 未完成的任务保留;已终结的任务,每个会话的每个运行时保留最新的 20 个。 |
_logs | 每个插件一个 200 行的环。在日志器第一次写入时修剪,之后每写 20 次修剪一次,所以一次执行可能短暂超出。 |
trace_events | 默认 30 天(COVEL_TRACE_RETENTION_DAYS,0 表示全部保留;变量没设时,采用玩家在“设置”里的选择,它存在 server_settings)。每次提交后按会话清理;启动时和之后每天一次,对所有会话清理。最新一个回合的行保留:执行恢复要读它们。 |
events(事件记录) | 与 trace_events 同样的期限,在同样的清理里进行。事件总线为它发布的每个事件存一份副本,其中包括每个发生变化的插件数据行的完整值。事件发布之后,没有代码再读这些行,唯一的例外是另一个 pod 按 ID 取一个超大的事件。 |
随会话保留、随会话删除的记录: turn_results、runtime_outputs、tool_calls、interactions、player_inputs、运行时导出的各个修订,以及逻辑回合账本和开场尝试账本。
它们每次执行只有一行或几行,存的是结果而不是提示词(提示词在 trace_events 里)。每一类都有会点名读取旧行的地方:
- 重试要指定一个来源回合;
- 媒体引用扫描会读每一条回合结果和运行时输出;
- 调试 API 会列出运行时输出和交互;
- 后台任务读取的是“它的来源执行开始那一刻”的导出。
它们不会整份复制进浏览器检查点(见 Browser-Private Protocol)。什么时候该重新考虑:服务端存储的会话,数据库主要被这些表占据,而不是消息、快照和 trace。首先该看的是 tool_calls(服务端没有代码读它)和早于“最早的未完成任务”的导出修订。
SQLite 文件什么时候会变小。 SQLite 不会把已删除行占的页还给文件系统,除非文件是用 auto_vacuum = INCREMENTAL 创建的。
- 当前版本创建的数据库是这个模式。删除 trace 之后会跟一次有上限的
PRAGMA incremental_vacuum,所以清理后文件会缩小。 - 更早创建的数据库文件保持原来的模式:释放的页会被新行复用,文件不再增长,但也不会变小。
- 要转换它,得在服务停止时做一次完整的
VACUUM(先在同一个连接上设置PRAGMA auto_vacuum = INCREMENTAL)。服务端在游玩过程中绝不会做这件事。
turn_results 是执行产物的权威来源。
- 重试从它保存的完整运行时结果(包括 effects 和规范值)重建种子;媒体引用扫描也直接读它。
runtime_outputs是更窄的投影,供运行时输出 API 使用。- 行在提交之前写入,由
setTurnResultCommitStatus结算。回合提交成功时,它还会把“写入被丢弃的运行时”的结果改写为failed并附上错误,所以重试绝不会把一个被丢弃的结果当作成功来做种子。
状态所有者登记
新增表或会话字段时,先在 state-ownership.ts 登记它的所有者和保存范围。KERNEL_TABLE_OWNERSHIP 覆盖 SQLite 和 PostgreSQL 的全部 Drizzle 表,记录会话或全局作用域,以及快照、浏览器检查点和分叉的处理方式。SESSION_FIELD_OWNERSHIP 覆盖 SessionRecord 的每个字段,记录所有者和是否进入快照。这份登记只描述现有行为,不驱动数据复制或写入。
- 表名与两个后端的 schema 完全一致。会话作用域包含级联登记里的子表、父表
sessions和由 MediaStore 管理的media_refs。 - 标为
captured的表恰好对应SnapshotPayload里的数据集。消息游标、显示边界和压缩标签不是消息行数据集。 - 标为
exported的表恰好对应检查点领域,包括记录数组、生命周期数组和单条记录。这里表示检查点有该领域,不保证复制全部行;例如工具调用和事件数组目前导出为空。 - 会话字段的快照登记与
SnapshotSessionState中的同名字段一致。类型检查还要求新增SessionRecord、快照或检查点字段时补齐对应登记或测试映射。
plugin_data 的行归 plugin_id 所有;_ 命名空间和 __kernel: 所有者是内核分区。控制面命名空间不进入快照和分叉。座位等内核会话状态放在会话记录上。三张内核状态表已在数据 schema 8 中移除。需要重建 schema 7 的 SQLite 数据库(包括桌面数据库)和 PostgreSQL 开发数据库卷,其中的快照也随之删除。BrowserVault、检查点和快照载荷的版本保持不变。
分叉行为已登记,但 store 测试无法依赖 apps/server,因此不核对分叉实现。会话 metadata 的子键不在字段检查范围内;目前没有重置路径,操作记录表也尚未创建,本登记不为它们声明行为。
Plugin-data ownership and reserved names
面向插件的数据 API 同时绑定会话 ID 和插件所有者。命名空间只是这个所有者分区里的一个名字,不能用它选中别的所有者。插件的普通写入先缓冲成提案,再以来源所有者的身份提交。
列表顺序是确定的。 每个插件数据列表都按 (createdAt, pluginId, namespace, key) 排序,在 MemoryStore、SQLite 和 PostgreSQL 上都按字节比较(PostgreSQL 用 COLLATE "C")。
- 重写一行只改它的值和
updatedAt,id和createdAt不变。 - 同一次提交写入的行
createdAt相同,这时由行的键决定顺序,而不是随机的行 ID。 - 所以会进入提示词的列表(世界维度、运行时自己的数据),在每个回合、在分支里、在同一份脚本会话的另一次运行里,顺序都一样。
还有两类会进入提示词的列表,在每个后端上也有完整的顺序:
listCharacters返回(createdAt, id),ID 按字节比较。不指定顺序的话,PostgreSQL 会按行在磁盘上的位置返回,而更新过的行会挪位置。世界数据导入器给一次导入里的角色分配相隔一毫秒的创建时间,按作者书写的顺序,所以这个顺序就是角色名单的顺序。同步时重写一个角色会保留它的创建时间。listTurnMessages和listUncompactedTurnMessages返回(createdAt, order, id):同一毫秒的两条消息按管线顺序排列,玩家的消息在前。
谁能读写什么。
| 所有者 / 命名空间 | 由谁做主 | 插件能做什么 |
|---|---|---|
插件所有者,除自有 _hidden.* 外以 _ 开头的命名空间 | 内核 | 通过限定范围的 API 读取;不能做通用的写入或删除,未知的 _ 名字也一样。 |
插件所有者,_runtime_jobs | 持久化后台任务(分离的阶段、后台的手动或事件触发) | 只读;状态转换由运行时 worker 负责。 |
插件所有者,_logs | 运行时日志环 | 通过数据 API 只读;条目通过限定范围的日志器产生。 |
插件所有者,_hidden.<namespace> | visibility: hidden 的世界数据和游玩中加入的隐藏内容 | 只有所属插件自己的代码能读写(REST 和 LLM 数据工具不能写);不出现在公开数据 API、扩展处理器、LLM 数据工具和提示词注入里。 |
当前生效的维度提供者,_dimensions | 已采纳的定义、当前值和版本 | 读自己的记录;只能通过 dimension.initialize / dimension.update 和经过校验的导入/同步批量 CAS 写入。 |
当前生效的维度提供者,_dimension-settlements | 叙事结算的义务和回执 | 读自己的记录;来源登记由宿主负责,状态转换要经过校验,不能通用写入。 |
插件所有者,message | 插件 | 普通的、基于提案的数据;界面宿主为声明过的消息面板预取并转发它,不解读它的业务形状。 |
插件所有者,普通名字,如 blocks、definitions、characters、blueprints | 插件 | 读自己的数据,通过提案写入。旧的角色镜像不会重新创建。 |
__kernel:<subsystem> 所有者,包括 __kernel:vector 和 __kernel:triggers | 内核 | 通过绑定插件的 store 或扩展 API、插件数据的 REST 读取、plugin-data.changed 事件都看不到。这是一个所有者分区,不是插件的命名空间。 |
下划线前缀由框架管理。 通用 REST 写入和模型数据工具不能写任何 _ 命名空间。插件自有代码只获准写自己的 _hidden.*;其余名字即使尚未使用,也保持只读,避免未来的内核记账数据被插件改写。
_memory仍受保护,尽管旧的记忆镜像和队列已经移除;这不意味着有兼容读取或数据恢复。- 任务和日志保持它们现有的快照/分支包含策略。
- 内核拥有的行(比如每个运行时的触发账本),和会话的其他插件数据一样,随快照、分支和检查点一起走。
维度数据。 维度记录和回执使用现有的 plugin_data。
- 定义和当前值共用一条带版本的记录。
- 回执保留冻结的来源定义和读取版本,不复制叙事文本。
- 快照、分支和 BrowserVault 检查点的传输既保留数据,也保留待办的义务。
- 公开的会话视图只暴露当前值条目和回执摘要,绝不暴露维护规则或初始值。
见 World Model 和 batch CAS。
NOTE
这些是 API 层面的权限边界,不是加密,也不是进程沙箱。
Stability commitment:保留 _ 前缀是一项永久的设计决定。新的内核子系统可以引入更多 _<name> 命名空间,这不算破坏性变更。插件自己的普通数据使用不带 _ 的命名空间;隐藏内容使用框架规定的自有 _hidden.*。不能假设其他内核保留名字可写。
有界的读取
执行读取。
queryTurnResults(只返回根产物)和queryTraceEvents在解码载荷之前先过滤。- 回合产物持久化
retryScope,与 trace 的保留期无关;时间戳相同时保持插入顺序。 queryPluginData支持跨会话按索引读取命名空间,以及可选的顶层字符串过滤。维度屏障只请求待处理的回执;worker 的维护工作共用一份队列快照。- 读取或维护这份快照期间收到的唤醒信号,留在队列里等下一轮。对账只推进到快照的“开始读取”时间。
deleteEventsBefore提供显式的事件日志保留。
这改变了开发数据库的 schema(turn_results.retry_scope、日志位置、PostgreSQL 的设定集小数顺序)。受影响的开发数据库需要重建,不提供旧 schema 的迁移。
MemoryStore 的消息索引。 MemoryStore 在消息数组旁边维护一张全局的“消息 ID → 偏移”映射。
- 普通插入拒绝已占用的 ID;玩家输入的采纳保持它单独的幂等契约。
- 追加和采纳时查身份,不用扫描累积的对话。
- 事务和保存点会同时给这两个结构拍快照;删除会话时把剩下的偏移重建一次。
- 这并不限制整份检查点导入、集合快照或历史读取的开销,它们仍随存储的数据量增长。
提示词读取有上限。 进入提示词的读取在存储边界处就限定了大小。
getLatestPlayerInput(sessionId)按createdAt降序、ID 字节序选出一条表单提交,所以执行准入不用加载整个输入日志。getPluginDataPromptWindow(sessionId, pluginId, namespace, maxEntries)返回{ entries, total }。条数不超过上限时按创建顺序返回;超过上限时,取最早的一半,加上最近更新的其余部分,并去掉重复的锚点。- SQL 后端计数时不加载 JSON,并在解码记录之前就应用条数限制。MemoryStore 实现相同的选择规则和归属契约。