69 lines
5.2 KiB
TypeScript
69 lines
5.2 KiB
TypeScript
/**
|
||
* 恢复图像 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;
|
||
}
|