docs(runtime): explain durable storage and recovery JSON contracts

This commit is contained in:
2026-11-12 14:15:02 +08:00
parent 5f2bf2d142
commit c3131a71bd
2 changed files with 27 additions and 10 deletions
+13 -4
View File
@@ -8,7 +8,9 @@ import {isSealedRecoveryImage,sealedRecoveryByteLength,encodedRecoveryByteLength
import {createImportBudget,type ImportScheduling} from './import-budget.ts';
import {fault} from '../canvas-core/values.ts';
/** 长字符串每段至多4096 UTF-16单元;避免截断代理对,再分别转义并拼接为一个合法 JSON 字符串。 */
/** 按最多 4096 UTF-16 单元切原字符串,跨边界代理对一起留到下段;孤立代理由原生 JSON 转义。
* 长串只输出一对外层引号,各段去掉 stringify 引号后拼接,保持完整字符串的转义/字节语义。
* 段上限不是转义后字符/UTF-8 字节上限,单段 stringify 仍是同步工作。 */
function* stringTokens(value:string):Generator<string>{
if(value.length<=4096){yield JSON.stringify(value);return;}
yield '"';
@@ -16,7 +18,9 @@ function* stringTokens(value:string):Generator<string>{
yield '"';
}
/** Only traverse codec-owned normalized JSON. Small owned subtrees use native stringify. */
/** 保留数组/对象原顺序,显式输出分隔符;≤16KiB 已知子树直接 stringify,降低 token 分配开销。 */
/** 只遍历已封存的规范 JSON:数组按索引、对象按 Object.keys 顺序输出,显式生成分隔符。
* codec 有精确 UTF-8 大小事实且 ≤16KiB 的对象/数组直接原生 stringify;没有大小事实不猜其成本。
* 不自行解释 toJSON/getter、稀疏/undefined 或执行深度校验;这些应在封存捕获阶段已规范化/约束。 */
function* tokens(value:unknown):Generator<string>{
if(value&&typeof value==='object'){
const size=encodedRecoveryByteLength(value);if(size!==undefined&&size<=16384){yield JSON.stringify(value);return;}
@@ -26,12 +30,17 @@ function* tokens(value:unknown):Generator<string>{
if(typeof value==='string')yield* stringTokens(value);else yield JSON.stringify(value);
}
/** Preserve exact JSON.stringify bytes; scheduling never exposes a partial recovery image. */
/** 输出完成的 Blob;check 在工作/await 边界复查 leader/生命周期,失败不返回半成品。 */
/** 返回完整 application/json Blob,不发布 Root、不写存储或持续产出可消费的字节流。
* 私有 sealed 品牌才走 token 分片并核对精确字节;外部 image 同步原生 stringify,不因 Object.freeze 获得快速路径。
* check 由宿主实现 leader/身份围栏,在入口、聚合片段及 await 前后/末尾调用;内部 signal 从不主动 abort。
* 所有 BlobPart 累积到结束,合作调度只限制部分同步工作,不是全文件零缓冲或整体内存硬预算。
* 任一错误不返回部分 Blob;此处不再 validate RecoveryImage/schema,输入有效性由 codec/存储边界保证。 */
export async function serializeRecoveryImage(image:RecoveryImage,options:ImportScheduling&{check?:()=>void}={}):Promise<Blob>{
options.check?.();
// 外部输入不信任所有权/尺寸缓存,继续走原生同步 stringify,不能冒称这一路也全部可分片。
if(!isSealedRecoveryImage(image)){const bytes=new Blob([JSON.stringify(image)],{type:'application/json'});options.check?.();return bytes;}
// 小 token 先聚合成约8K字符的片段,控制 BlobPart 数量;不是每个标点都创建独立 Blob。
// token 先聚合到至少 8192 个输出字符串 UTF-16 单元再 flush,单 token 可使片段超过目标;不是 8KiB 字节上限。
// 每次 flush 给预算记 128 单位,粗时钟也能按累积工作让出;不对每个标点单独创建 BlobPart/读取时钟。
const budget=createImportBudget(new AbortController().signal,options),parts:BlobPart[]=[];let pending:string[]=[],length=0;
for(const token of tokens(image)){
pending.push(token);length+=token.length;
+14 -6
View File
@@ -6,19 +6,25 @@
import type { Json } from '../canvas-core/types.ts';
/** Portable durable-store contract. Browser database types belong to the adapter. */
/** 账号/工作区/项目共同隔离持久事实;同 projectId 不得串用其它账号的存储。 */
/** 持久键由账号/工作区/项目组成,不含 view/navigation/authEpoch;身份退役另由宿主与 leader 围栏处理。
* 类型声明不捕获字符串、不验证权限,adapter 应按此作用域隔离事实,不能仅用 projectId 作全局键。 */
export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string };
/** 可恢复单 Root 图像;formatVersion 是解释器版本,rootRevision 与文档 localRevision 分开。 */
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
/** 同一提交携带恢复图像、文件写入/删除及可选共享 depot cut,adapter 保证合同中的原子性。 */
/** 恢复图像与可选文件增删/depot 清单的同次原子提交合同;不表示对象构造后已保存。
* files 是 Blob 原字节,image/depot 保存逻辑引用/证明;adapter 需在异步前捕获输入并核对身份/删除资格。
* 需要删除时还须显式 fileDeletion 能力,不能将独立异步删文件视为与 Root 同一事务。 */
export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly depot?:import('./asset-depot.ts').DepotCut; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] };
/** 排他写者句柄;fence 区分代次,active 检查加实际存储校验阻止旧 writer 在新 leader 后继续写。 */
/** 排他写者句柄,fence 标识持有代次,active 可随 release/失效改变;只读类型不代表 active 永远不变。
* 宿主准入检查不能替代 adapter 在真实 I/O/提交处复核 scope/fence/有效性,接口自身不实现跨进程锁。 */
export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise<void> };
/** 文件盘点只返回元信息,不为扫描加载全部 Blob 字节。 */
export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string};
/** 分页盘点绑定 rootRevision,nextAfter 是后续页游标,不能跨变更拼接成完整清理依据。 */
export type FileInventoryPage={readonly files:readonly StoredFileInfo[];readonly rootRevision:number;readonly nextAfter?:string};
/** 宿主实现的存储能力;可选字段必须显式探测,缺能力不能假装已完成扫描或原子删除。 */
/** 宿主实现的持久协议,声明本身不执行校验/深冻结、获取锁或操作数据库。
* 可选能力须显式探测;引擎的 ledger generation/Root 发布只是内存事实,commit 成功确认后才推进可靠水位。
* close 为同步本地句柄收尾,异步 leader.release 独立;失败/取消的 I/O 不自动说明数据从未提交。 */
export type StoragePort = {
/** 可选工作区共享资源仓库,文件/引用保留通过 depot 合同处理。 */
readonly assetDepot?:import('./asset-depot.ts').AssetDepotPort;
@@ -28,9 +34,11 @@ export type StoragePort = {
readonly fileDeletion?: 'atomic-v1';
/** 取得作用域 leader,已有有效写者时拒绝,而不是悄悄允许两个 writer。 */
claim(scope: StorageScope): Promise<StorageLeader>;
/** 在有效 leader 下恢复最后可靠图像;null 表示没有已提交快照。 */
/** 恢复最后已提交图像,不直接把任意 payload 当合法 Root;codec/engine 仍需版本、结构与身份校验。
* null 只表示该作用域没有已提交快照,不证明所有本地文件/共享 depot 记录也不存在。 */
recover(leader: StorageLeader): Promise<RecoveryImage | null>;
/** 原子提交 DurableBatch 并返回真实完成的 rootRevision,不能提前在请求入队时 resolve。 */
/** adapter 捕获/验证并原子提交批次,返回实际完成的 durableRootRevision;不能在入队/单条写请求成功时提前 resolve。
* 回执不直接修改 editor 或放行 scheduler,调用方还需核对水位并按同切点恢复调度。 */
commit(leader: StorageLeader, batch: DurableBatch): Promise<{ readonly durableRootRevision: number }>;
/** 读取指定文件的 Blob,缺失用 undefined;不从文档资源引用猜字节已经存在。 */
readFile(leader: StorageLeader, id: string): Promise<Blob | undefined>;