Files
Endoleap/src/lib/canvas-runtime/journal-delta.ts
T

69 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 恢复图像 JSON 的有界差分与纯回放。差分不是业务 Change,不携带用户命令/网络操作身份。
* 校验摘要覆盖精确 JSON 字节,因此对象键顺序改变时直接替换子树,不能只保值而改序。
*/
import type { Json } from '../canvas-core/types.ts';
import { fault, validJson } from '../canvas-core/values.ts';
/** 对象路径段为字符串,数组路径段为非负整数;空路径表示整份根值。 */
type Path = readonly (string | number)[];
/** set 替换子树,splice 修改数组尾/区间;不提供任意执行代码或隐式创建路径。 */
export type JournalPatch = {op:'set';path:Path;value:Json}|{op:'splice';path:Path;start:number;deleteCount:number;items:Json[]};
/** Exact JSON property order is retained because recovery checksums cover serialized bytes. */
/** 生成至多10000项差分;超限返回 null,让存储层选择完整图像,不返回被截断的补丁。
* 输入应是已捕获的 JSON 树:本函数不校验深度/循环、不 clone/freeze,set 与 splice.items 的子值可借用 after。
* 10000 限制补丁数而非遍历量/编码字节;共同前缀仍逐项检查,没有时间预算或异步让出。 */
export function diffJournal(before:Json,after:Json):JournalPatch[]|null {
const patches:JournalPatch[]=[];
/** 相同身份立即跳过;数组递归共同前缀并 splice 长度差,对象仅同键同序时逐字段递归。 */
function visit(a:Json,b:Json,path:(string|number)[]){
if(a===b||patches.length>10000)return;
if(Array.isArray(a)&&Array.isArray(b)){
const common=Math.min(a.length,b.length);for(let i=0;i<common;i++){
const previous=a[i],next=b[i];
// Shared immutable rows need no path allocation or recursive call.
if(previous!==next)visit(previous,next,[...path,i]);
}
if(a.length!==b.length)patches.push({op:'splice',path,start:common,deleteCount:a.length-common,items:b.slice(common)});
}else if(a&&b&&typeof a==='object'&&typeof b==='object'&&!Array.isArray(a)&&!Array.isArray(b)){
// 增删键或键序变化时替换整个对象,避免逐字段重建改变精确序列化顺序。
const left=Object.keys(a),right=Object.keys(b);
if(left.length===right.length&&left.every((key,i)=>key===right[i]))for(const key of right){
const previous=a[key],next=b[key];if(previous!==next)visit(previous,next,[...path,key]);
}
else patches.push({op:'set',path,value:b});
}else patches.push({op:'set',path,value:b});
}
visit(before,after,[]);return patches.length>10000?null:patches;
}
/** Copy only changed ancestors; never mutate the preceding checkpoint or another patch's input. */
/** 逐补丁重放到私有结果,每次仅复制受影响祖先;原 checkpoint 和其它已持有值不被改动。
* 不检查 before 整树,不验证前像/摘要/业务身份;可信恢复链和最终 Root 校验由调用方完成。
* set 直接采用 patch.value,splice 只新建数组外壳,未改子树也共享;输出不自动冻结或深隔离。
* 后续补丁失败会抛错且不返回中间结果,但外来 getter 副作用不在此处回滚。 */
export function applyJournal(before:Json,patches:readonly JournalPatch[]):Json {
if(!Array.isArray(patches)||patches.length>10000)throw fault('corrupt_recovery_delta');
let result=before;
for(const patch of patches){
if(!patch||!['set','splice'].includes(patch.op)||!Array.isArray(patch.path)||patch.path.length>64||patch.path.some((key:unknown)=>typeof key!=='string'&&(typeof key!=='number'||!Number.isSafeInteger(key)||key<0)))throw fault('corrupt_recovery_delta');
// validJson 限定值域;它不是稠密数组/描述符捕获器,恢复字节的 JSON.parse 边界仍很关键。
if(patch.op==='set'?!validJson(patch.value):!Array.isArray(patch.items)||!validJson(patch.items))throw fault('corrupt_recovery_delta');
/** 验证路径自有属性/数组下标后递归替换,set/splice 终点另外校验 JSON 与区间边界。 */
function replace(value:Json,depth:number):Json {
// 空路径直接替换根或对根数组 splice;非空路径只允许已有自有属性,不隐式创建中间对象。
if(depth===patch.path.length){
if(patch.op==='set')return patch.value;
if(!Array.isArray(value)||!Number.isSafeInteger(patch.start)||!Number.isSafeInteger(patch.deleteCount)||patch.start<0||patch.deleteCount<0||patch.start>value.length||patch.deleteCount>value.length-patch.start)throw fault('corrupt_recovery_delta');
return [...value.slice(0,patch.start),...patch.items,...value.slice(patch.start+patch.deleteCount)];
}
const key=patch.path[depth];
if(!value||typeof value!=='object'||!Object.hasOwn(value,key)||(Array.isArray(value)?typeof key!=='number'||key>=value.length:typeof key!=='string'))throw fault('corrupt_recovery_delta');
const copy=Array.isArray(value)?[...value]:{...value};
// 安全定义自有键,避免 __proto__ 等路径变成赋值副作用;也保留已存在键的次序。
Object.defineProperty(copy,key,{value:replace((value as Record<string|number,Json>)[key],depth+1),enumerable:true,writable:true,configurable:true});return copy;
}
result=replace(result,0);
}
return result;
}