docs(canvas): explain recovery encoding and history replay evidence

This commit is contained in:
2026-11-12 00:13:52 +08:00
parent da00490ded
commit af8e2fe76b
8 changed files with 187 additions and 1 deletions
@@ -1,3 +1,8 @@
/**
* 恢复图像的 JSON 值捕获、冻结与分片编码。保持既有 JSON 表示语义,并为已证明不可变的数据缓存结果/字节数。
* 它不是业务 schema 校验器:非有限数按 JSON 归一为 null,对象 undefined 字段省略、数组槽位转 null。
* 外部可变数据必须在首次 await 前捕获;仅来源/结构可证明安全的部分延后遍历,调度预算是软目标。
*/
import {hasDetachedJsonData} from '../canvas-core/json-data.ts';
import type { Json } from '../canvas-core/types.ts';
import { fault, freeze, validJson } from '../canvas-core/values.ts';
@@ -5,18 +10,25 @@ import {isDataContainer} from '../canvas-core/data-containers.ts';
import {createImportBudget, type ImportScheduling} from './import-budget.ts';
import type { RecoveryImage } from './storage-port.ts';
/** 归一后的值、子树高度、可复用证明和精确 JSON UTF-8 字节数;undefined 表示 JSON 省略值。 */
type Encoded = {value:Json|undefined;height:number;reusable:boolean;bytes:number};
// WeakMap 仅按被证明安全的源身份缓存;sealed/imageSizes 是本 codec 的来源事实,外部 freeze 不能伪造。
const immutable=new WeakMap<object,Encoded>(),sealed=new WeakSet<object>(),encodedSizes=new WeakMap<object,number>(),imageSizes=new WeakMap<object,number>();
/** 计算 UTF-8 字节数(代理对计4字节);调用时多为 JSON.stringify 后的已转义字符串。 */
function utf8Length(text:string){let bytes=0;for(let i=0;i<text.length;i++){const c=text.charCodeAt(i);if(c<128)bytes++;else if(c<2048)bytes+=2;else if(c>=0xd800&&c<=0xdbff&&i+1<text.length&&text.charCodeAt(i+1)>=0xdc00&&text.charCodeAt(i+1)<=0xdfff){bytes+=4;i++;}else bytes+=3;}return bytes;}
/** 计算原子值/属性名的 JSON 表示成本,包含引号及转义,不使用 JS UTF-16 length 代替。 */
const scalarBytes=(value:unknown)=>utf8Length(JSON.stringify(value));
/** JSON value semantics, with reuse only for transitively frozen plain data properties. */
/** 创建一次编码的递归访问器,visiting 检测当前路径循环;共享不可变节点可从全局弱缓存复用。 */
function createEncoder() {
const visiting=new Set<object>();
/** 按原 JSON 语义捕获值并检查最大深度64,返回携带不可变性/大小证明的 Encoded。 */
function visit(value:unknown,depth:number,key:string):Encoded {
if(value===null||typeof value==='string'||typeof value==='boolean'){if(depth>64)throw fault('invalid_recovery_payload');return{value,height:0,reusable:true,bytes:scalarBytes(value)};}
if(typeof value==='number'){if(depth>64)throw fault('invalid_recovery_payload');const normalized=Number.isFinite(value)?(Object.is(value,-0)?0:value):null;return{value:normalized,height:0,reusable:true,bytes:scalarBytes(normalized)};}
if(value===undefined||typeof value==='symbol')return{value:undefined,height:0,reusable:true,bytes:0};
/** 对 toJSON/装箱值/自定义原型使用原生 JSON 兼容路径,每次重新求值,不缓存隐藏可变行为。 */
const fallback=()=>{
// Preserve the previous JSON codec for toJSON, boxed values and custom prototypes.
// These can depend on hidden mutable state and are deliberately never cached.
@@ -24,6 +36,7 @@ function createEncoder() {
if(encoded!==undefined&&!validJson(encoded,depth))throw fault('invalid_recovery_payload');
return{value:freeze(encoded),height:0,reusable:false,bytes:encoded===undefined?0:scalarBytes(encoded)};
};
// BigInt 默认由原生 JSON 抛错,有自定义 toJSON 时保持其既有语义;函数通常被省略。
if(typeof value==='bigint'||typeof value==='function')return fallback();
if(typeof value!=='object')throw fault('invalid_recovery_payload');
const object=value as object,cached=immutable.get(object);
@@ -34,8 +47,10 @@ function createEncoder() {
if(depth>64)throw fault('invalid_recovery_payload');
if(visiting.has(object))throw fault('invalid_recovery_payload');visiting.add(object);
const toJSON=Object.getOwnPropertyDescriptor(object,'toJSON');
// 浅 frozen 不够:本对象必须无访问器,且所有实际子值也都具有可复用证明。
let reusable=Object.isFrozen(object)&&(!toJSON||Object.hasOwn(toJSON,'value')),height=0,bytes=2;
try {
// 数组按 length 捕获,空洞/undefined 转 null;无自有数据属性的槽位会使结果不可缓存。
if(Array.isArray(object)){
const result:Json[]=[];
for(let index=0;index<object.length;index++){
@@ -44,6 +59,7 @@ function createEncoder() {
}
const encoded={value:Object.freeze(result) as unknown as Json,height,reusable,bytes};encodedSizes.set(result,bytes);if(reusable)immutable.set(object,encoded);return encoded;
}
// 对象只遍历可枚举自有字符串键;defineProperty 安全写入特殊键,并保留原属性次序。
const result:Record<string,Json>={};let properties=0;
for(const name of Object.keys(object)){
const descriptor=Object.getOwnPropertyDescriptor(object,name);if(!descriptor||!Object.hasOwn(descriptor,'value'))reusable=false;
@@ -55,38 +71,49 @@ function createEncoder() {
}
return visit;
}
/** 同步捕获任意兼容输入为冻结 JSON;顶层被省略时拒绝,不能产生无 payload 的图像。 */
export function encodeRecoveryValue(input:unknown):Json {
const result=createEncoder()(input,0,'');if(result.value===undefined)throw fault('invalid_recovery_payload');return result.value;
}
/** 恢复图像版本,与 IDB schema/journal 版本分开,由具体恢复特性选择。 */
type ImageVersion = RecoveryImage['formatVersion'];
/** 校验受支持的图像版本和 revision 关系,localRevision 不得超过 rootRevision。 */
function validateHeader(formatVersion:ImageVersion,rootRevision:number,localRevision:number){
if(![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image');
}
/** 组装本 codec 拥有的不可变信封,并登记精确字节成本;只登记真正由本模块构造的对象。 */
function finishImage(formatVersion:ImageVersion,rootRevision:number,localRevision:number,encoded:Json):RecoveryImage {
const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encoded});sealed.add(image);imageSizes.set(image,JSON.stringify({...image,payload:null}).length-4+(encoded&&typeof encoded==='object'?encodedSizes.get(encoded)??scalarBytes(encoded):scalarBytes(encoded)));return image;
}
/** Only codec-owned, validated immutable images qualify for the storage copy fast path. */
/** 同步编码入口,完成 header 与值域捕获后才授予 sealed 来源标记。 */
export function sealRecoveryImage(formatVersion:ImageVersion,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
validateHeader(formatVersion,rootRevision,localRevision);
return finishImage(formatVersion,rootRevision,localRevision,encodeRecoveryValue(payload));
}
/** 暂存可跨 suspension 保持值语义的原子输入,稍后按预算完成归一化/字符串字节计算。 */
type Scalar = null|string|number|boolean|undefined|symbol;
/** 编码准备计划:原子值、已捕获 Encoded,或可安全延后读取的结构/预捕获 children。 */
type Prepared = Scalar | Encoded | {source:object;depth:number;children?:Prepared[];keys?:string[]};
// Read unknown objects synchronously, before the first suspension. Shape-owned
// containers can include external values; each such occurrence is captured now.
// Detached JSON can also defer after checking its prototype environment.
/** 在第一次让出前固定不安全输入;shape-owned 外壳仍逐个捕获外部子值,不能只信 Object.freeze。 */
function prepareEncoding(input:unknown) {
const normalize=createEncoder(),visiting=new Set<object>();
let plainEnvironment:boolean|undefined;
/** 同步规范化陌生对象;任何用户行为可能改原型环境,结束即使失败也使环境缓存失效。 */
const capture=(value:unknown,depth:number,key:string)=>{
try{return normalize(value,depth,key);}finally{plainEnvironment=undefined;}
};
/** 仅自有 detached JSON 且基础原型无 toJSON 钩子时,才允许整个子树延后读取。 */
const detached=(value:object)=>hasDetachedJsonData(value)&&(plainEnvironment??=(
Object.getPrototypeOf(Object.prototype)===null&&Object.getPrototypeOf(Array.prototype)===Object.prototype&&
!Object.getOwnPropertyDescriptor(Object.prototype,'toJSON')&&!Object.getOwnPropertyDescriptor(Array.prototype,'toJSON')
));
/** 递归构造安全计划;原子值延后,已缓存值复用,其余未知子树立刻捕获。 */
function prepare(value:unknown,depth:number,key:string):Prepared {
const kind=typeof value;
if(value===null||kind==='string'||kind==='number'||kind==='boolean'||kind==='undefined'||kind==='symbol'){
@@ -101,9 +128,11 @@ function prepareEncoding(input:unknown) {
// Detached JSON has no accessors, custom prototypes or mutable
// descendants. Its full walk can wait; foreign freezes cannot grant this.
if(detached(value))return {source:value,depth};
// 陌生 frozen 对象仍走同步捕获,不凭外壳不可变就假设子值/访问器也稳定。
if(!isDataContainer(value))return capture(value,depth,key);
// Prototype toJSON changes must retain ordinary JSON semantics, including
// accessors. Inspect descriptors without evaluating them an extra time.
// 检查描述符而非重复读取 getter,保留继承 toJSON 的原生 JSON 语义。
let prototype:object|null=value,hasToJSON=false;
while(prototype){if(Object.getOwnPropertyDescriptor(prototype,'toJSON')){hasToJSON=true;break;}prototype=Object.getPrototypeOf(prototype);}
if(hasToJSON)return capture(value,depth,key);
@@ -122,17 +151,22 @@ function prepareEncoding(input:unknown) {
/** Capture external data immediately; cooperatively encode stable owned tables.
* Time slicing is soft: synchronous capture of an unknown subtree remains indivisible.
* The returned image has exactly the synchronous codec's wire representation. */
/** 异步入口:先同步固定输入,再按预算遍历安全部分;结果表示与同步 codec 相同。
* 同步 capture、单个原生操作仍不可抢占,不能由 async 名称推导整个编码无长任务。 */
export async function sealRecoveryImageAsync(formatVersion:ImageVersion,rootRevision:number,localRevision:number,payload:unknown,options:ImportScheduling={}):Promise<RecoveryImage> {
validateHeader(formatVersion,rootRevision,localRevision);
const {plan,normalize}=prepareEncoding(payload);
const scheduler=(globalThis as typeof globalThis & {scheduler?:{yield?:()=>Promise<void>}}).scheduler;
// 默认使用可用 scheduler.yield;没有时由预算适配器回退,所有语义仍由同一个 normalize 决定。
const budget=createImportBudget(new AbortController().signal,{...options,yieldTask:options.yieldTask??(typeof scheduler?.yield==='function'?()=>scheduler.yield!():undefined)});
/** 生成器只在预算要求时 yield Promise;逐项累加深度/字节并冻结结果,尚未完成的图像不外发。 */
function* encode(plan:Prepared,depth=0,key=''):Generator<Promise<void>,Encoded,void>{
if(plan===null||typeof plan!=='object')return normalize(plan,depth,key);
if('height' in plan)return plan;
// Preparation must capture every unsafe occurrence before yielding. Once an
// earlier occurrence has assembled a proven immutable source, later plans
// can reuse that fact without rebuilding the same JSON containers.
// 同一源先前出现已建立完整不可变证明时,可复用派生结果,但仍检查当前深度余量。
const cached=immutable.get(plan.source);
if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;}
if(depth>64)throw fault('invalid_recovery_payload');
@@ -154,18 +188,23 @@ export async function sealRecoveryImageAsync(formatVersion:ImageVersion,rootRevi
const name=keys![index];bytes+=(properties++?1:0)+scalarBytes(name)+1+child.bytes;
Object.defineProperty(result,name,{value:child.value,enumerable:true});height=Math.max(height,1+child.height);
}
// 工作确实发生在 yield 之间,而不是先同步编码完后仅空等一个异步边界。
const pause=budget.step();if(pause)yield pause;
}
const encoded={value:Object.freeze(result) as Json,height,reusable,bytes};encodedSizes.set(result,bytes);if(reusable)immutable.set(plan.source,encoded);return encoded;
}
// 宿主循环消费生成器并等待让出;最终 Encoded 完成才调用 finishImage。
const iterator=encode(plan);let step=iterator.next();while(!step.done){await step.value;step=iterator.next();}
if(step.value.value===undefined)throw fault('invalid_recovery_payload');
return finishImage(formatVersion,rootRevision,localRevision,step.value.value);
}
/** 查询私有 sealed 来源,供存储跳过重复防御复制;不接受外部伪造品牌字段。 */
export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);}
/** Exact UTF-8 JSON size, available only for codec-owned immutable images. */
/** 只对 codec-owned 图像返回精确总字节,普通外来图像返回 undefined 走防御路径。 */
export function sealedRecoveryByteLength(image:RecoveryImage){return imageSizes.get(image);}
/** Internal codec ownership/size fact, never inferred from external Object.freeze. */
/** 已编码 JSON 子树的精确大小事实,供 serializer 选择小子树原生 stringify 快路径。 */
export function encodedRecoveryByteLength(value:object){return encodedSizes.get(value);}