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,7 @@
/**
* 为历史恢复选择可证明的事件区间:普通原生 transition,或最近一次 adoption 的虚拟事件再接后续原生事件。
* 不把已采纳后压缩过的历史误当作原始 sequence 直接重放;所有解释器/先后关系都需显式检查。
*/
import type { OperationIntent } from '../canvas-core/types.ts';
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
import { validateHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts';
@@ -6,29 +10,39 @@ import type { EngineSnapshot } from './contracts.ts';
import type { OperationRecord } from './ledger.ts';
import { validateHistoryReplay, type HistoryReplay } from './history-replay.ts';
/** 有 record 表示对应逻辑操作;没有 record 则是纯历史 adjustment。 */
export type HistorySourceEvent={readonly record:OperationRecord|undefined;readonly transition:HistoryTransition};
/** 事件序列与可选 owning adoption 证据;count 标识虚拟事件前缀长度。 */
export type HistoryEventSource={readonly events:readonly HistorySourceEvent[];readonly adoption?:{readonly count:number;readonly replay:HistoryReplay;readonly transition:HistoryTransition}};
/** Resolve either a native interval or the owning adoption's proved virtual events, followed by later native events. */
/** 找最早有文档效果的 pending 所属事件源;缺失/歧义/陈旧审计不能猜,直接拒绝。 */
export function historyEventSource(snapshot:EngineSnapshot,pending:readonly OperationIntent[]):HistoryEventSource{
const content=pending.filter(intent=>intent.changes.length).sort((a,b)=>a.sequence-b.sequence),first=content[0];if(!first)return{events:[]};
const records=snapshot.runtime.ledger.operations,audits=[...snapshot.runtime.recoveries.values()],owners=audits.filter(audit=>audit.decisions.some(decision=>decision.replacementId===first.id));
if(owners.length>1)throw fault('recovery_history_adoption_ambiguous',[first.id]);
/** 确认操作/尺寸效果解释器存在,未来数据保留不等于允许当前代码解释执行。 */
const interpreter=(record:OperationRecord)=>{if(record.intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[record.intent.id]);if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);};
const pendingIds=new Set(content.map(intent=>intent.id));
/** 校验事件不重复,且 pending 子序列与逻辑 sequence 精确一致,不能遗漏或交换。 */
const order=(events:readonly HistorySourceEvent[])=>{const seen=new Set<string>(),active:string[]=[];for(const event of events)if(event.record){const id=event.record.intent.id;if(seen.has(id))throw fault('recovery_history_event_order',[id]);seen.add(id);if(pendingIds.has(id))active.push(id);}if(active.length!==content.length||active.some((id,index)=>id!==content[index].id))throw fault('recovery_history_event_order');};
/** 从操作提取已验证的原生历史 transition,缺证据不构造推测事件。 */
const native=(record:OperationRecord):HistorySourceEvent=>{interpreter(record);const transition=record.intent.historyTransition;if(!transition)throw fault('recovery_history_transition_missing',[record.intent.id]);validateHistoryTransition(transition);return{record,transition};};
// 无 owning adoption 时用原生区间;区间内还有 adoption 就不能越过其边界盲目回放。
if(!owners.length){
const events=[...records.values()].filter(record=>record.intent.sequence>=first.sequence&&record.intent.changes.length).sort((a,b)=>a.intent.sequence-b.intent.sequence).map(native);
const start=events[0]?.transition.rootRevision??Infinity;if(audits.some(audit=>(audit.historyTransition?.rootRevision??audit.baseRootRevision+1)>=start))throw fault('recovery_history_adoption_replay_required');
for(let i=1;i<events.length;i++)if(events[i].transition.rootRevision<=events[i-1].transition.rootRevision)throw fault('recovery_history_event_order');order(events);return{events};
}
// 有 adoption 时要求其 replay/transition 齐全且没有更晚审计覆盖;先重建虚拟历史事实。
const audit=owners[0],revision=audit.baseRootRevision+1,replay=audit.historyReplay;
if(!replay||!audit.historyTransition)throw fault('recovery_history_adoption_evidence_missing',[audit.id]);validateHistoryReplay(replay,revision);validateHistoryTransition(audit.historyTransition);
if(audits.some(other=>other.baseRootRevision+1>revision))throw fault('recovery_history_adoption_stale',[audit.id]);
// adjustment 不需要服务操作;operation 虚拟事件必须能定位真实账本记录并具有可用解释器。
const witnessed=new Set<string>(),events:HistorySourceEvent[]=replay.events.map(event=>{
if(event.kind==='adjustment')return{record:undefined,transition:event.transition};const record=records.get(event.operationId);if(!record?.intent.changes.length)throw fault('history_replay_operation_missing',[event.operationId]);interpreter(record);witnessed.add(event.operationId);return{record,transition:event.transition};
});
for(const intent of content)if(!witnessed.has(intent.id)&&!(intent.historyTransition&&intent.historyTransition.rootRevision>revision))throw fault('recovery_history_transition_missing',[intent.id]);
// 只接 adoption revision 之后的原生事件,再核对 revision 单调与 pending 完整顺序。
const later=[...records.values()].filter(record=>record.intent.changes.length&&record.intent.historyTransition&&record.intent.historyTransition.rootRevision>revision).sort((a,b)=>a.intent.sequence-b.intent.sequence).map(native);
let previous=revision;for(const event of later){if(event.transition.rootRevision<=previous)throw fault('recovery_history_event_order');previous=event.transition.rootRevision;}
order([...events,...later]);return{events:[...events,...later],adoption:{count:events.length,replay,transition:audit.historyTransition}};
@@ -1,3 +1,8 @@
/**
* 混合原生编辑、已确认操作、undo/redo 和异步尺寸修正的历史恢复规划器。
* 先由已保存 transition 反推出私有起点,再按事件顺序应用用户审阅选择,生成新 history 与 replay 证据。
* 所有游标/栈/entry 变化都留在本次规划对象内;外层 coordinator 成功后才统一发布 Root,不能直接修改现有 history。
*/
import type { Change, Document, FieldChange, HistoryEntry, HistoryState, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
import { applyHistoryTransition, describeHistoryTransition } from '../canvas-core/history-transition.ts';
import { invert } from '../canvas-core/document.ts';
@@ -10,46 +15,61 @@ import type { EngineSnapshot } from './contracts.ts';
import { planCreationSize, validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
/** Native history events are reconstructed from evidence; all cursors remain private until the recovery root is accepted. */
/** 创建一次恢复会话,要求有效 history 容量和完整事件证据;返回 project→accept→finish/evidence 协作接口。 */
export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:readonly OperationIntent[],registry:Registry,limit:number){
if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_history_limit');
// historyEventSource 可能提供 adoption 虚拟事件;给每项补私有 before/after 以便核对后续取舍。
const pendingIds=new Set(pending.map(intent=>intent.id)),source=historyEventSource(snapshot,pending);
const events=source.events.map(event=>({...event,before:undefined as HistoryState|undefined,after:undefined as HistoryState|undefined}));
let history=snapshot.history,adopted:HistoryState|undefined;
try{
// 逆序条件回放元数据,确定原区间起点;adoption 还要确认其 anchor 与整体 transition 一致。
for(let index=events.length-1;index>=0;index--){const event=events[index];if(source.adoption&&index===source.adoption.count-1)adopted=history;event.after=history;history=applyHistoryTransition(history,event.transition,'reverse');event.before=history;}
if(source.adoption){if(!adopted)throw fault('recovery_history_transition_conflict');const before=applyHistoryTransition(adopted,source.adoption.transition,'reverse'),expected=applyHistoryTransition(before,source.adoption.replay.anchor,'forward');if(!sameHistory(history,expected))throw fault('recovery_history_transition_conflict');}
}catch{throw fault('recovery_history_transition_conflict');}
// 新证据的所有 transition 都绑定即将发布的同一个 Root revision,不冒充多次服务提交。
const baseline=history,replayEvents:HistoryReplayEvent[]=[],adoptionRevision=snapshot.rootRevision+1;
/** 记录私有 history 的一次真实变化;关联操作即使 no-op 也留证据,调整事件不造 operation。 */
function recordStep(before:HistoryState,entryId:Id|undefined,operationId?:Id){const transition=describeHistoryTransition(before,history,adoptionRevision,entryId,!!operationId);if(transition)replayEvents.push(operationId?freeze({kind:'operation' as const,operationId,transition}):freeze({kind:'adjustment' as const,transition}));}
// next 是严格事件游标;touched 区分本轮已审阅项,retired 解释被清redo/容量淘汰或无效果移除的项。
let next=0;const touched=new Set<Id>(),retired=new Set<Id>();let remap:((change:Change)=>Change)|undefined;
let sizeHistory: HistoryState | undefined;
/** 捕获双栈数组和 entry 表成员为新只读 history,不暴露私有可变容器。 */
const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap<Id,HistoryEntry>)=>Object.freeze({undo:freeze([...undo]),redo:freeze([...redo]),entries:readMap(entries)});
/** 当前处理的 intent 必须恰好位于 next;不能按网络返回先后跳过或交换历史。 */
const eventFor=(intent:OperationIntent)=>{const event=events[next];if(!event||event.record?.intent.id!==intent.id)throw fault('recovery_history_event_order',[intent.id]);return event;};
/** 处理一个已有分组项的确认前缀:纯字段仅保留 fresh 仍含其最终效果的字段链。
* 几何/path 要求 owner 未变;结构性前缀必须可完整逆向执行,否则拒绝猜测部分历史。 */
function reviewPrefix(entry:HistoryEntry,document:Document):readonly Change[]{
const prefix=entry.changes;
if(prefix.every(change=>change.kind==='field'&&!['ownerCanvasId','source','target'].includes(change.field))){
// 同目标/代次/字段以最后 after 判断是否仍有效,再保留该字段原有链,不能抹掉组内其它手动修改。
const fields=prefix as readonly FieldChange[],last=new Map<string,FieldChange>();const key=(change:FieldChange)=>JSON.stringify([change.target.id,change.target.incarnation,change.field]);for(const change of fields)last.set(key(change),change);
for(const change of fields)if(['geometry','path'].includes(change.field)&&document.elements.get(change.target.id)?.ownerCanvasId!==snapshot.runtime.acknowledged.elements.get(change.target.id)?.ownerCanvasId)throw fault('recovery_history_prefix_conflict',[entry.id]);
const keep=new Set([...last].filter(([,change])=>{const target=document.elements.get(change.target.id);if(target?.incarnation!==change.target.incarnation)return false;const field=change.field.startsWith('data.')?change.field.slice(5):undefined,value=field===undefined?(target as unknown as Record<string,unknown>)[change.field]:target.data[field]??null;return equal(value,change.after)&&(field===undefined||Object.hasOwn(target.data,field)===(change.afterPresent??true));}).map(([key])=>key));return fields.filter(change=>keep.has(key(change)));
}
try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[entry.id]);}return prefix;
}
/** 丢弃一个未保留的 user 贡献时,仍审查同 entry 中已确认的旧前缀,不能连旧贡献一起删除。 */
function skipUser(intent:OperationIntent,document:Document){
const event=eventFor(intent),id=event.transition.entryId;if(!id)return;const entry=history.entries.get(id);
if(!entry){retired.add(id);return;}
if(touched.has(id)||!history.undo.includes(id))return;
const changes=reviewPrefix(entry,document),entries=new Map(history.entries);if(changes.length)entries.set(id,freeze({...entry,changes}));else{entries.delete(id);retired.add(id);}history=state(history.undo.filter(key=>entries.has(key)),history.redo.filter(key=>entries.has(key)),entries);
}
/** 加入保留/已确认的 user 贡献,沿用原 entry 元数据并维护分组顺序、清redo和容量淘汰。 */
function addUser(intent:OperationIntent,changes:readonly Change[],operationId:Id,pending:boolean,document?:Document){
const event=eventFor(intent),id=event.transition.entryId;if(!id)return;
const original=event.after!.entries.get(id);if(!original)throw fault('recovery_history_transition_conflict',[id]);
const entries=new Map(history.entries),undo=[...history.undo];for(const removed of history.redo){entries.delete(removed);retired.add(removed);}
const prior=entries.get(id),originalAppend=event.before!.entries.has(id),appending=!!prior;
if(originalAppend&&(!prior&&!retired.has(id)||prior&&undo.at(-1)!==id)||!originalAppend&&prior)throw fault('recovery_history_traversal_order',[id]);
// 第一次触及已有分组项时按 fresh document 审阅前缀,之后只追加本轮已经规划的变化。
const prefix=pending&&prior&&!touched.has(id)&&document?reviewPrefix(prior,document):prior?.changes??[];
const {changes:_changes,operationIds:_operations,...metadata}=original;
entries.set(id,freeze({...metadata,changes:[...prefix,...changes],operationIds:[...(prior?.operationIds??[]),operationId]}));if(!appending)undo.push(id);while(undo.length>limit){const removed=undo.shift()!;entries.delete(removed);retired.add(removed);}history=state(undo,[],entries);retired.delete(id);if(pending)touched.add(id);
}
/** 重放无服务操作的历史调整,只支持有明确证据的项修正/删除;不允许无根据重新排列双栈。 */
function applyAdjustment(event:typeof events[number]){
const before=event.before!,after=event.after!,removed=new Set([...before.entries.keys()].filter(id=>!after.entries.has(id)));
if(!equal(after.undo,before.undo.filter(id=>!removed.has(id)))||!equal(after.redo,before.redo.filter(id=>!removed.has(id))))throw fault('recovery_history_adjustment_conflict');
@@ -57,32 +77,40 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
for(const patch of event.transition.entries){const current=entries.get(patch.id),original=before.entries.get(patch.id),desired=after.entries.get(patch.id);if(equal(current,desired))continue;if(!original||!equal(current,original))throw fault('recovery_history_adjustment_conflict',[patch.id]);if(desired)entries.set(patch.id,desired);else{entries.delete(patch.id);retired.add(patch.id);}}
history=state(history.undo.filter(id=>entries.has(id)),history.redo.filter(id=>entries.has(id)),entries);
}
/** 先消费下一个待审阅意图之前的已确认/调整事件;碰到另一 pending 不能越过它自动处理。 */
function advance(intent?:OperationIntent){
while(next<events.length&&(!intent||events[next].record?.intent.id!==intent.id)){
const event=events[next],record=event.record,before=history;
if(!record)applyAdjustment(event);
else{
// applied/elided 是已有事实,未知/未解决结果不允许被历史解释器当作已确认贡献。
if(pendingIds.has(record.intent.id))throw fault('recovery_history_event_order',[record.intent.id]);if(!['applied','elided'].includes(record.resolution??''))throw fault('recovery_history_event_unresolved',[record.intent.id]);
if(record.intent.historyEffect)addConfirmedSize(record.intent);else if(record.intent.origin==='user')addUser(record.intent,record.intent.changes,record.intent.id,false);else if(['undo','redo'].includes(record.intent.origin))addConfirmedTraversal(record.intent);else throw fault('recovery_history_event_origin_unsupported',[record.intent.id]);
}
recordStep(before,event.transition.entryId,record?.intent.id);next++;
}
}
/** 对应已验证前后 entry 的变化位置提取代次映射,供其它历史目标跟随显式 restore。 */
function incarnationRemap(before:HistoryEntry,after:HistoryEntry){
const maps=new Map<Id,Map<number,number>>();
/** 仅同 ID 且代次确实改变时登记,不能把两个不同元素意外映为同一生命周期。 */
const pair=(a:{id:Id;incarnation:number}|null,b:{id:Id;incarnation:number}|null)=>{if(a&&b&&a.id===b.id&&a.incarnation!==b.incarnation){const values=maps.get(a.id)??new Map();values.set(a.incarnation,b.incarnation);maps.set(a.id,values);}};
before.changes.forEach((a,index)=>{const b=after.changes[index];if(a.kind==='field'&&b?.kind==='field')pair(a.target,b.target);else if(a.kind==='element'&&b?.kind==='element'){pair(a.before,b.before);pair(a.after,b.after);}});
/** 仅重写 field target 与整元素 before/after 代次,不修改 canvas 记录或冻结服务请求。 */
return(change:Change):Change=>{const record=<T extends {id:Id;incarnation:number}>(value:T|null)=>value?{...value,incarnation:maps.get(value.id)?.get(value.incarnation)??value.incarnation}:null;if(change.kind==='field')return freeze({...change,target:record(change.target)!});if(change.kind==='element')return freeze({...change,before:record(change.before),after:record(change.after)});return change;};
}
/** 已 applied 的 undo/redo 是事实:只有当前审阅前缀仍表示完全相同效果才允许移动栈。 */
function addConfirmedTraversal(intent:OperationIntent){
const event=eventFor(intent),id=event.transition.entryId,undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo;
if(!id||source.at(-1)!==id)throw fault('recovery_history_traversal_order',id?[id]:[]);
const entry=history.entries.get(id)!,before=event.before!.entries.get(id),after=event.after!.entries.get(id),changes=intent.origin==='undo'?invert(intent.changes):intent.changes;
// An applied action is a fact. Its history may move only if the reviewed prefix still denotes that exact forward effect.
if(!before||!after||!equal(entry.changes,before.changes)||!equal(changes,after.changes))throw fault('recovery_history_confirmed_traversal_conflict',[id]);
// restore 映射只推进其它可遍历历史;原操作/请求的历史身份和字节保持原样。
const entries=new Map(history.entries),map=incarnationRemap(before,after);for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(map)}));
entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,intent.id]}));source.pop();destination.push(id);history=state(undo,redo,entries);
}
/** 已确认默认尺寸效果只修原 entry/后续条件,不移动双栈;当前历史必须仍匹配原前置证据。 */
function addConfirmedSize(intent: OperationIntent) {
validateCreationSizeEffect(intent.historyEffect);
const event = eventFor(intent), entries = new Map(history.entries);
@@ -95,8 +123,10 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
history = state(history.undo, history.redo, entries);
}
return{
/** 为当前 intent 计算文档候选,不立即移动对应新历史;accept 在替代操作身份确定后接续。 */
project(document:Document,intent:OperationIntent,context:IntentContext,keep:boolean){
if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined;sizeHistory=undefined;
// 尺寸效果通过专用 planner 重算 history;keep=false 不发布旧自动尺寸。
if (intent.historyEffect) {
validateCreationSizeEffect(intent.historyEffect);
if (!keep) return { document, changes: [] as readonly Change[] };
@@ -104,6 +134,7 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
sizeHistory = plan.history;
return { document: plan.document, changes: plan.changes };
}
// 遍历项必须核对新栈顶;被审阅改过的 entry 使用新变化计划,不能套用旧 inverse 覆盖 fresh 内容。
if(['undo','redo'].includes(intent.origin)){
if(!id)throw fault('recovery_history_traversal_order');const entry=history.entries.get(id);
if(!keep){
@@ -116,13 +147,16 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
if(touched.has(id)){try{const planned=planHistoryChanges(document,intent.origin==='undo'?invert(entry.changes):entry.changes,registry);remap=planned.remap;return planned;}catch{throw fault('recovery_history_traversal_conflict',[id]);}}
remap=incarnationRemap(event.before!.entries.get(id)!,event.after!.entries.get(id)!);
}
// 普通保留意图经显式审阅后重新规划;丢弃返回零文档变化,不自行伪造成功回执。
return keep?rebaseIntent(document,intent,context,registry):{document,changes:[] as readonly Change[]};
},
/** 接受 project 的取舍/替代操作,更新私有双栈并记 replay;每项完成后 next 单调前进。 */
accept(intent:OperationIntent,replacement:OperationIntent|undefined,keep:boolean,before:Document,after:Document){
if(!intent.changes.length)return;const previous=history,event=eventFor(intent),id=event.transition.entryId;
if (intent.historyEffect) {
if (keep) {
if (!sizeHistory) throw fault('recovery_history_size_conflict');
// 自动尺寸沿用原创建历史,只有确实产生 replacement 才追加新逻辑操作关联。
const entries = new Map(sizeHistory.entries), owner = entries.get(intent.historyEffect.entryId);
if (!owner) throw fault('recovery_history_size_conflict');
if (replacement) entries.set(owner.id, freeze({ ...owner, operationIds: [...owner.operationIds, replacement.id] }));
@@ -130,7 +164,9 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
history = state(sizeHistory.undo, sizeHistory.redo, entries);
}
}
// 普通编辑按贡献维护分组;没有 replacement 时仍保留可证明的旧前缀。
else if(intent.origin==='user'){if(replacement)addUser(intent,replacement.changes,replacement.id,true,before);else skipUser(intent,before);}
// 新遍历还要能从 after 反向回放;无剩余效果则退休而非制造虚假 redo。
else if(['undo','redo'].includes(intent.origin)&&id&&keep){
const entries=new Map(history.entries),undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo,entry=entries.get(id)!;source.pop();
if(replacement){const changes=intent.origin==='undo'?invert(replacement.changes):replacement.changes;try{planHistoryChanges(after,intent.origin==='undo'?changes:invert(changes),registry);}catch{throw fault('recovery_history_traversal_conflict',[id]);}if(remap)for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)}));entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,replacement.id]}));destination.push(id);}else{entries.delete(id);retired.add(id);}
@@ -138,7 +174,9 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
}
recordStep(previous,id,replacement?.id);next++;
},
/** 消费剩余已确认事件后返回最终候选 history;未审阅 pending 仍会被 advance 拒绝。 */
finish(){advance();return history;},
/** 输出原 history→baseline 的 anchor 及有序增量,和本次 adoption 一起持久化以支持再次重开。 */
evidence():HistoryReplay{return freeze({version:1 as const,anchor:describeHistoryTransition(snapshot.history,baseline,adoptionRevision,undefined,true)!,events:replayEvents});},
};
}
+9
View File
@@ -1,20 +1,29 @@
/**
* 一次本地恢复采纳的历史证据:anchor 把旧 history 移到回放起点,events 解释之后的操作/调整。
* 这些 transition 是同一原子发布的元数据,不是新服务写入;重复重开必须能验证相同历史结果。
*/
import type { HistoryState, Id } from '../canvas-core/types.ts';
import { applyHistoryTransition, validateHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts';
import { fault, equal, validJson } from '../canvas-core/values.ts';
/** operation 关联稳定逻辑 ID;adjustment 只修历史元数据,不伪造一条服务操作。 */
export type HistoryReplayEvent={readonly kind:'operation';readonly operationId:Id;readonly transition:HistoryTransition}|{readonly kind:'adjustment';readonly transition:HistoryTransition};
/** version=1 的 anchor+有序事件;所有 transition 绑定同一次 adoption Root revision。 */
export type HistoryReplay={readonly version:1;readonly anchor:HistoryTransition;readonly events:readonly HistoryReplayEvent[]};
/** Local evidence only. The anchor and event deltas explain one atomic adoption, not new service writes. */
/** 检查证据版本/JSON、anchor无entry、事件 revision 一致和 operation ID 唯一;未知版本拒绝解释。 */
export function validateHistoryReplay(value:unknown,revision:number,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryReplay{
if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const raw=value as Record<string,unknown>;
if(raw.version!==1)throw fault('history_replay_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_replay');validateHistoryTransition(raw.anchor,checkChanges);if(raw.anchor.rootRevision!==revision||raw.anchor.entryId!==undefined||!Array.isArray(raw.events))throw fault('invalid_history_replay');const ids=new Set<Id>();
for(const value of raw.events){if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const event=value as Record<string,unknown>;validateHistoryTransition(event.transition,checkChanges);if(event.transition.rootRevision!==revision)throw fault('invalid_history_replay');if(event.kind==='operation'){if(typeof event.operationId!=='string'||!event.operationId||ids.has(event.operationId))throw fault('invalid_history_replay');ids.add(event.operationId);}else if(event.kind!=='adjustment'||event.operationId!==undefined)throw fault('invalid_history_replay');}
}
/** 纯条件回放 anchor/events;可选校验逻辑 ID 存在,带 entry 的事件必须在该项关联同 operation。 */
export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,hasOperation?:(id:Id)=>boolean):HistoryState{
validateHistoryReplay(replay,replay.anchor.rootRevision);let history=applyHistoryTransition(before,replay.anchor,'forward');
for(const event of replay.events){if(event.kind==='operation'&&hasOperation&&!hasOperation(event.operationId))throw fault('history_replay_operation_missing',[event.operationId]);history=applyHistoryTransition(history,event.transition,'forward');if(event.kind==='operation'&&event.transition.entryId&&!history.entries.get(event.transition.entryId)?.operationIds.includes(event.operationId))throw fault('history_replay_operation_mismatch',[event.operationId]);}
return history;
}
/** 比较两栈和全部 entry;entry 表按 ID 排序,避免 Map 插入顺序差异被误认作历史不一致。 */
export function sameHistory(left:HistoryState,right:HistoryState):boolean{
const value=(history:HistoryState)=>({undo:history.undo,redo:history.redo,entries:[...history.entries].sort(([a],[b])=>a.localeCompare(b))});return equal(value(left),value(right));
}
+11
View File
@@ -1,11 +1,19 @@
/**
* 恢复图像 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,让存储层选择完整图像,不返回被截断的补丁。 */
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)){
@@ -26,12 +34,14 @@ export function diffJournal(before:Json,after:Json):JournalPatch[]|null {
visit(before,after,[]);return patches.length>10000?null:patches;
}
/** Copy only changed ancestors; never mutate the preceding checkpoint or another patch's input. */
/** 逐补丁重放到私有结果,每次仅复制受影响祖先;原 checkpoint 和其它已持有值不被改动。 */
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');
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 {
if(depth===patch.path.length){
if(patch.op==='set')return patch.value;
@@ -41,6 +51,7 @@ export function applyJournal(before:Json,patches:readonly JournalPatch[]):Json {
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);
@@ -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);}
+11
View File
@@ -1,8 +1,14 @@
/**
* 把已捕获的恢复图像编码成精确 JSON UTF-8 Blob,按 token/片段分批让出。
* 序列化不发布部分 Root;只能对 codec-owned 规范化 JSON 使用分片快速路径,外部图像保留原生防御路径。
* 最终 Blob 大小必须匹配编码器的精确证明,避免悄悄变更检查点字节表示。
*/
import type {RecoveryImage} from './storage-port.ts';
import {isSealedRecoveryImage,sealedRecoveryByteLength,encodedRecoveryByteLength} from './recovery-encoding.ts';
import {createImportBudget,type ImportScheduling} from './import-budget.ts';
import {fault} from '../canvas-core/values.ts';
/** 长字符串每段至多4096 UTF-16单元;避免截断代理对,再分别转义并拼接为一个合法 JSON 字符串。 */
function* stringTokens(value:string):Generator<string>{
if(value.length<=4096){yield JSON.stringify(value);return;}
yield '"';
@@ -10,6 +16,7 @@ function* stringTokens(value:string):Generator<string>{
yield '"';
}
/** Only traverse codec-owned normalized JSON. Small owned subtrees use native stringify. */
/** 保留数组/对象原顺序,显式输出分隔符;≤16KiB 已知子树直接 stringify,降低 token 分配开销。 */
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;}
@@ -19,12 +26,16 @@ 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/生命周期,失败不返回半成品。 */
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。
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;
// 检查身份后让出,恢复执行再检查;避免异步期间已退役的 writer 继续采用结果。
if(length>=8192){parts.push(pending.join(''));pending=[];length=0;options.check?.();await budget.step(128);options.check?.();}
}
if(pending.length)parts.push(pending.join(''));options.check?.();
+53 -1
View File
@@ -1,3 +1,9 @@
/**
* EngineSnapshot 与可序列化 RecoveryImage 的边界。图像同时保存 working/history/operations 与 runtime,
* 不把它们分别恢复成可互相漂移的 store;Blob 字节走 DurableBatch.files,不放进 JSON payload。
* 编码按所需特性选择格式,解码校验表/跨表身份、依赖、请求、历史、资源与任务证据后才返回候选 Root。
* 解码不发 HTTP、不重启外部副作用,最终恢复准入和可靠存储仍由 engine/adapter 负责。
*/
import {referencedResources} from './resources.ts';
import {validateSharedAsset,type SharedAssetBinding} from './asset-depot.ts';
import {validateLoadingRuntime,type LoadingState} from './loading.ts';
@@ -25,8 +31,11 @@ import type { RecoveryImage } from './storage-port.ts';
// Membership is stable only for core-owned read views. Never infer ownership
// from Object.freeze: a frozen native Map can still change through .set().
// 仅核心自有只读 Map 的成员稳定可缓存;Object.freeze(native Map) 并不禁止 set,不能作为来源证明。
const entryViews=new WeakMap<object,readonly (readonly [unknown,unknown])[]>();
/** 可选表缺省的共享空数组,只表示没有表成员,不表示其数据已完成业务校验。 */
const noEntries=dataArray([]);
/** 把 Map 编成有序元组数组;自有读视图可复用外壳,其 value 是否可复用由 codec 另查。 */
function mapValue<K,V>(map?:ReadonlyMap<K,V>):readonly (readonly [K,V])[]{
if(!map)return noEntries;
if(!isOwnedMapView(map))return [...map];
@@ -35,17 +44,24 @@ function mapValue<K,V>(map?:ReadonlyMap<K,V>):readonly (readonly [K,V])[]{
return value as readonly (readonly [K,V])[];
}
/** 显式选择规范化文档字段并建立框架拥有的外层容器,不假定嵌套外部记录天然不可变。 */
function encodeDocumentValue(document:Document){
// Freeze our own containers only. The codec still independently proves whether
// each nested value can be reused, including external mutable records.
return dataRecord({projectId:document.projectId,canvases:dataArray(document.canvases.values()),elements:dataArray(document.elements.values()),tombstones:dataArray([...document.tombstones].map(pair=>dataArray(pair)))});
}
// 缓存工厂信封的序列化外壳,不缓存/跳过组件数据校验,也不将该外壳视为发布文档。
const documentViews=new WeakMap<Document,ReturnType<typeof encodeDocumentValue>>();
/** 工厂文档可复用成员外壳;未知来源文档重新枚举,防止冻结外壳掩盖可变 Map 成员。 */
function documentValue(document:Document){
if(!isFactoryDocument(document))return encodeDocumentValue(document);
let value=documentViews.get(document);if(!value){value=encodeDocumentValue(document);documentViews.set(document,value);}return value;
}
/** Plain records only: functions, credentials, renderer objects, URLs and Map methods never serialize. */
/** 显式选择模型/运行事实;不采集 view、renderer、显示句柄或 Blob 字节。
* stored asset URL 和 sourceURL 属于已选协议数据,会按原值保存;不应把本函数当成任意数据的脱敏器。 */
/** 从同一 snapshot 选择文档/双栈/意图及所有运行事实,按当前特性选择兼容 envelope 格式。
* 格式优先级覆盖读完整性、原生文档、数组、共享binding、partial loading、尺寸/文件退休等;
* 与 parserVersion、component dataVersion、IDB/journalVersion 无关,不能互相代替。 */
function recoveryInput(snapshot: EngineSnapshot) {
const payload = dataRecord({
document: documentValue(snapshot.document),
@@ -55,29 +71,40 @@ function recoveryInput(snapshot: EngineSnapshot) {
});
return [[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.integrityVersion===2)?17:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5)?16:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document')?15:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-json-array')?14:snapshot.runtime.assetBindings?.size?13:snapshot.runtime.loading?(snapshot.runtime.loading.version===6?12:snapshot.runtime.loading.version===5?11:snapshot.runtime.loading.version===4?10:snapshot.runtime.loading.version===3?9:snapshot.runtime.loading.version===2?8:7):[...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload] as const;
}
/** 同步编码完整 Root,返回 sealed 不可变图像;编码成功尚不代表已 durable。 */
export function encodeRecovery(snapshot:EngineSnapshot):RecoveryImage {
return sealRecoveryImage(...recoveryInput(snapshot));
}
/** 相同输入合同的分片编码入口,预算与外部数据捕获边界由 recovery-encoding 明确处理。 */
export function encodeRecoveryAsync(snapshot:EngineSnapshot,options:ImportScheduling={}):Promise<RecoveryImage> {
return sealRecoveryImageAsync(...recoveryInput(snapshot),options);
}
/** 解码边界的未验证对象字段表,后续每个字段仍需具体校验。 */
type ObjectValue = Record<string, unknown>;
/** 要求非 null 的非数组对象,否则统一报告图像损坏。 */
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
/** 要求数组;不能把缺失字段或对象静默转成空数组。 */
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw fault('corrupt_recovery_image'); return value; }
/** ID/代码等字段要求非空字符串;允许空标签的地方另行检查。 */
function string(value: unknown): string { if (typeof value !== 'string' || !value) throw fault('corrupt_recovery_image'); return value; }
/** 验证安全整数与调用点指定下限,用于 revision、计数、代次和版本。 */
function integer(value: unknown, min = 0): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min) throw fault('corrupt_recovery_image'); return value; }
/** 验证非空字符串数组;是否允许重复由具体持久字段/关联约束决定。 */
function strings(value: unknown): string[] { return array(value).map(string); }
/** 解码二元组表,拒绝重复 ID 与非二元组;完成后返回运行时安全只读 Map。 */
function table<T>(value: unknown, decode: (entry: unknown, id: Id) => T): ReadonlyMap<Id, T> {
const result = new Map<Id, T>();
for (const item of array(value)) { const pair = array(item), id = string(pair[0]); if (pair.length !== 2 || result.has(id)) throw fault('corrupt_recovery_image'); result.set(id, decode(pair[1], id)); }
return readMap(result);
}
/** 重建 document/tombstones,调用核心完整校验树、owner、端点与已知组件 schema。 */
function readDocument(value: unknown, registry: Registry): Document {
const raw = object(value);
const tombstones = table(raw.tombstones, entry => integer(entry, 1));
const document = createDocument(string(raw.projectId), array(raw.canvases) as never, array(raw.elements) as never, tombstones);
validateDocument(document, registry); return document;
}
/** 校验持久 Change 的基础形状/目标/字段白名单/存在性;不在恢复解码时执行历史修改。 */
function changes(value: unknown): readonly Change[] {
for (const item of array(value)) {
const change = object(item);
@@ -94,11 +121,13 @@ function changes(value: unknown): readonly Change[] {
}
return freeze(value as Change[]);
}
/** 已知版本校验历史增量,未来版本原样保留;真正回放前必须另查解释器,不能自动重写。 */
function historyTransition(value:unknown){
const raw=object(value);integer(raw.version,1);
// Preserve future evidence with its original bytes. Its interpreter must be checked before any replay.
if(raw.version===1){try{validateHistoryTransition(value,changes);}catch{throw fault('corrupt_recovery_image');}}
}
/** 校验意图身份与已知 upload/run 字段约束,保留 schema version;可恢复不等于可直接执行。 */
function intent(value: unknown, id: Id): OperationIntent {
const raw = object(value);
if (string(raw.id) !== id) throw fault('corrupt_recovery_image');
@@ -116,24 +145,28 @@ function intent(value: unknown, id: Id): OperationIntent {
strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes);
return freeze(value as OperationIntent);
}
/** 双栈 ID 必须唯一且精确覆盖 entries;每个 entry 关联的 operation 必须存在,不从网络队列推断历史。 */
function readHistory(history:ObjectValue,operations:ReadonlyMap<Id,OperationIntent>):HistoryState {
const entries=table<HistoryEntry>(history.entries,(value,id)=>{const raw=object(value);if(string(raw.id)!==id||typeof raw.label!=='string')throw fault('corrupt_recovery_image');changes(raw.changes);for(const operationId of strings(raw.operationIds))if(!operations.has(operationId))throw fault('corrupt_recovery_image');if(raw.groupId!==undefined)string(raw.groupId);return freeze(value as HistoryEntry);});
const undo=strings(history.undo),redo=strings(history.redo),ids=[...undo,...redo];
if(new Set(ids).size!==ids.length||ids.length!==entries.size||ids.some(id=>!entries.has(id)))throw fault('corrupt_recovery_image');
return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries});
}
/** 防御恢复入口:捕获 JSON 后逐域校验,所有通过才返回冻结 Root;不部分发布合法前缀。 */
export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSnapshot & { readonly rootRevision: number; readonly runtime: RuntimeState } {
if (![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
integer(image.rootRevision); integer(image.localRevision);
if (image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
// Own the parsed records; mutating a caller's RecoveryImage must not mutate live state.
const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger);
// 部分加载的解释器与 envelope 有明确兼容关系;不能把完整图像误按 partial 或反向解释。
if(runtime.loading!==undefined&&image.formatVersion<13&&image.formatVersion!==(object(runtime.loading).version===6?12:object(runtime.loading).version===5?11:object(runtime.loading).version===4?10:object(runtime.loading).version===3?9:object(runtime.loading).version===2?8:7))throw fault('unsupported_recovery_version');
if(image.formatVersion>=7&&image.formatVersion<=12&&runtime.loading===undefined)throw fault('invalid_loading_state');
const operations = table(raw.operations, intent),restoredHistory=readHistory(history,operations);
if(image.formatVersion<6&&[...operations.values()].some(intent=>intent.historyEffect))throw fault('unsupported_recovery_version');
for(const operation of operations.values())if(operation.historyTransition?.version===1&&(operation.historyTransition.rootRevision<1||operation.historyTransition.rootRevision>image.rootRevision))throw fault('corrupt_recovery_image');
if(image.formatVersion===1&&array(runtime.imports??[]).length)throw fault('unsupported_recovery_version');
// core operations 与 ledger 的文档意图/历史证据互相核对;上传/无文档效果的控制动作另有归属。
const records = table<OperationRecord>(ledger.operations, (value, id) => {
const raw = object(value); intent(raw.intent, id); strings(raw.blockers); object(raw.basePresence);
if (((object(raw.intent).operationType === 'document.change'||object(raw.intent).operationType==='run.apply'&&array(object(raw.intent).changes).length>0) && !operations.has(id)) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image');
@@ -144,6 +177,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
});
if (operations.size !== [...records.values()].filter(record => (record.intent.operationType === 'document.change'||record.intent.operationType==='run.apply'&&record.intent.changes.length>0)).length) throw fault('corrupt_recovery_image');
// Validate the dependency DAG iteratively, including missing dependencies, without recursion overflow.
// 用 Kahn 迭代检查整个依赖 DAG,避免深链递归溢出;缺依赖/重复依赖/环均拒绝。
const counts = new Map<Id, number>(), dependents = new Map<Id, Id[]>(), ready: Id[] = [];
for (const [id, record] of records) {
const dependencies = record.intent.dependencies;
@@ -153,6 +187,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
}
for (let index = 0; index < ready.length; index++) for (const id of dependents.get(ready[index]) ?? []) { counts.set(id, counts.get(id)! - 1); if (!counts.get(id)) ready.push(id); }
if (ready.length !== records.size) throw fault('corrupt_recovery_image');
// 恢复原 body 字符串,不 parse 后重新 stringify 以免改变冻结字节;仅为身份核验读取已知协议字段。
const requests = table<FrozenRequest>(ledger.requests, (value, id) => {
const raw = object(value);
if (string(raw.id) !== id || typeof raw.body !== 'string') throw fault('corrupt_recovery_image');
@@ -162,12 +197,14 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
if (raw.kind === 'asset.upload' && (array(raw.canvasIds).length || Object.keys(object(raw.expectedVersions)).length || array(raw.operationIds).length !== 1)) throw fault('corrupt_recovery_image');
if (!['frozen', 'dispatch-marked', 'waiting', 'resolved'].includes(string(raw.phase)) || !['unresolved', 'accepted', 'applied', 'not-applied'].includes(string(raw.outcome))) throw fault('corrupt_recovery_image');
for (const operationId of strings(raw.operationIds)) if (records.get(operationId)?.requestId !== id || (raw.kind ?? 'document.change') !== records.get(operationId)?.intent.operationType) throw fault('corrupt_recovery_image');
// 已知 run.start body 必须与原操作目标/模型/参数/参与者完全对应,不能只因 request ID 相同就信任。
if(raw.kind==='run.start'){
if(array(raw.operationIds).length!==1)throw fault('corrupt_recovery_image');
const operation=records.get(string(array(raw.operationIds)[0]))!.intent;if(operation.operationType!=='run.start')throw fault('corrupt_recovery_image');
let body:ObjectValue;try{body=object(JSON.parse(raw.body as string));}catch{throw fault('corrupt_recovery_image');}
if(raw.ownerCanvasId!==operation.run.boardId||body.protocol_version!==2||body.request_id!==id||body.operation_id!==operation.id||body.node_id!==operation.run.target.id||body.target_incarnation!==operation.run.target.incarnation||body.model_id!==operation.run.modelId||body.prompt!==operation.run.prompt||body.propagate!==operation.run.propagate||!equal(body.parameters,operation.run.parameters)||!equal(body.expected_versions,raw.expectedVersions)||!equal([...strings(raw.canvasIds)].sort(),[...operation.canvasIds].sort()))throw fault('corrupt_recovery_image');
}
// cancel/apply 核对动作归属;cancel 无文档参与者,apply 要绑定目标代次与 output ID。
if(raw.kind==='run.cancel'||raw.kind==='run.apply'){
if(array(raw.operationIds).length!==1)throw fault('corrupt_recovery_image');const operation=records.get(string(array(raw.operationIds)[0]))!.intent;
if(operation.operationType!=='run.cancel'&&operation.operationType!=='run.apply')throw fault('corrupt_recovery_image');
@@ -180,14 +217,17 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
if (raw.retryAction !== undefined && !['submit', 'query'].includes(string(raw.retryAction))) throw fault('corrupt_recovery_image');
return freeze(value as FrozenRequest);
});
// 请求→操作和操作→请求双向校验,冻结状态不能缺少原请求身份。
for (const record of records.values()) if (record.requestId && !requests.has(record.requestId) || record.phase === 'frozen' && !record.requestId) throw fault('corrupt_recovery_image');
try {
// 本地零效果证明也要独立复验,working 墓碑必须已经含其水位,不能因服务未见过而复用身份。
validateElisions({ operations: records });
for (const record of records.values()) if (record.resolution === 'elided') { const original = operations.get(record.intent.id); if (!original || !equal(original.changes, record.intent.changes) || original.sequence !== record.intent.sequence || original.origin !== record.intent.origin) throw fault('invalid_elision'); }
const local = withLocalLifecycles(document, { operations: records });
if (!equal([...local.tombstones], [...document.tombstones])) throw fault('invalid_elision');
} catch (error) { if ((error as {code?:string}).code === 'elision_interpreter_unavailable') throw error; throw fault('corrupt_recovery_image'); }
const receipts=table(runtime.receipts,value=>freeze(value as Json));
// 控制依赖被零效果解决时必须有原请求的确定终态证明,404/传输错误不够。
for(const record of records.values())if(record.resolution==='not-applied'){
const request=record.requestId?requests.get(record.requestId):undefined;
if(!['run.start','run.cancel'].includes(record.intent.operationType)||record.phase!=='resolved'||request?.outcome!=='not-applied'||request.phase!=='resolved')throw fault('corrupt_recovery_image');
@@ -196,10 +236,12 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
}
const generation = integer(ledger.generation), durableGeneration = integer(ledger.durableGeneration);
if (durableGeneration > generation) throw fault('corrupt_recovery_image');
// ACK 与 working 同项目,但保持两份不同意义的文档;服务版本要求覆盖 ACK 的全部活 canvas。
const acknowledged = readDocument(runtime.acknowledged, registry);
if (acknowledged.projectId !== document.projectId) throw fault('project_mismatch');
const versions = table(runtime.versions, value => integer(value, 1));
for (const id of acknowledged.canvases.keys()) if (!versions.has(id)) throw fault('corrupt_recovery_image');
// 资源记录绑定唯一 upload 意图;解码/尺寸/退休子协议各自验版本,stored 不等于本地字节已存在。
const resources = table<ResourceRecord>(runtime.resources ?? [], (value, id) => {
const raw = object(value);
if (string(raw.id) !== id || string(raw.operationId) === id) throw fault('corrupt_recovery_image');
@@ -215,6 +257,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
return freeze(value as ResourceRecord);
});
for (const record of records.values()) if (record.intent.operationType === 'asset.upload' && resources.get(record.intent.resourceId)?.operationId !== record.intent.id) throw fault('corrupt_recovery_image');
// 跨项目采纳 binding 必须由对应 document.change 引用该资源,不能与本地 upload 资源表撞 ID。
const assetBindings=table<SharedAssetBinding>(runtime.assetBindings??[],(value,id)=>{
const binding=value as SharedAssetBinding;validateSharedAsset(binding);const row=object(value);
if(image.formatVersion<13||binding.asset.id!==id||resources.has(id)||!records.has(string(row.operationId)))throw fault('corrupt_asset_binding');
@@ -223,8 +266,10 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
return freeze(binding);
});
if(image.formatVersion===13&&!assetBindings.size||assetBindings.size&&runtime.loading!==undefined&&object(runtime.loading).version!==6)throw fault('unsupported_recovery_version');
// 显式 owner→资源 ID 清单与恢复 audit 互证;完整反向 pin 集仍由保留扫描派生。
const resourcePins = table(runtime.resourcePins ?? [], strings);
const replacementOwners=new Set<Id>();
// 审计备份、选择、原/替代操作和 history replay 是一个闭合证明,不能只保留一句 keep-local 标签。
const recoveries=table<RecoveryAudit>(runtime.recoveries??[],(value,id)=>{
const raw=object(value),backup=object(raw.backup);if(raw.historyTransition!==undefined){historyTransition(raw.historyTransition);if(object(raw.historyTransition).version===1&&object(raw.historyTransition).rootRevision!==(raw.baseRootRevision as number)+1)throw fault('corrupt_recovery_image');}if(string(raw.id)!==id||raw.formatVersion!==1||integer(raw.baseRootRevision)>=image.rootRevision)throw fault('corrupt_recovery_image');
const working=readDocument(backup.document,registry),base=readDocument(backup.acknowledged,registry),versions=table(backup.versions,value=>integer(value,1));
@@ -243,6 +288,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
validateHistoryReplay(replay,(raw.baseRootRevision as number)+1,changes);
if(!raw.historyTransition)throw fault('corrupt_recovery_image');
const local=new Set(localReplacements);
// 新替代意图的事件顺序必须按其真实 sequence;anchor+events 的效果还要等于整体 historyTransition。
const expectedOrder=[...localReplacements].sort((a,b)=>records.get(a)!.intent.sequence-records.get(b)!.intent.sequence);
const actualOrder=replay.events.flatMap(event=>event.kind==='operation'&&local.has(event.operationId)?[event.operationId]:[]);
if(!equal(actualOrder,expectedOrder))throw fault('corrupt_recovery_image');
@@ -262,6 +308,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
if(['superseded','discarded','satisfied'].includes(record.resolution??'')){const audit=record.recoveryId?recoveries.get(record.recoveryId):undefined;if(!audit||!audit.decisions.some(decision=>decision.operationId===record.intent.id))throw fault('corrupt_recovery_image');}
else if(record.recoveryId!==undefined||record.replacementId!==undefined)throw fault('corrupt_recovery_image');
}
// run 必须由已 applied 的原受理请求产生,轮询游标、待合并页和 output 事件均有身份/顺序约束。
const runs=table<RunRecord>(runtime.runs ?? [],(value,id)=>{
const raw=object(value),operation=records.get(string(raw.operationId)),request=requests.get(string(raw.requestId)),target=object(raw.target);
if(string(raw.id)!==id||operation?.intent.operationType!=='run.start'||operation.requestId!==raw.requestId||request?.kind!=='run.start'||request.outcome!=='applied'||operation.intent.run.boardId!==string(raw.boardId)||operation.intent.run.target.id!==string(target.id)||operation.intent.run.target.incarnation!==integer(target.incarnation,1))throw fault('corrupt_recovery_image');
@@ -277,21 +324,26 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
for(const value of array(raw.outputEvents ?? [])){const event=object(value),step=string(event.step_id);if(event.run_id!==id||event.type!=='step.output'||outputIds.has(step)||integer(event.seq,1)>Math.max(integer(raw.cursor),raw.pendingPage===undefined?0:integer(object(raw.pendingPage).next)))throw fault('corrupt_recovery_image');object(event.output);outputIds.add(step);}
return freeze({...value as RunRecord,outputEvents:array(raw.outputEvents ?? []) as never});
});
// 导入 job 有独立 validator;source/parser/read-proof 特性与 envelope 版本必须互相匹配。
const imports=table<ImportJob>(runtime.imports??[],(value,id)=>{const job=value as ImportJob;validateImportJob(job);if(job.id!==id||job.projectId!==document.projectId)throw fault('corrupt_recovery_image');return freeze(job);});
const hasJsonArrays=[...imports.values()].some(job=>job.source?.kind==='native-json-array');if(hasJsonArrays&&image.formatVersion<14||image.formatVersion===14&&!hasJsonArrays)throw fault('unsupported_recovery_version');
const hasReadProof=[...imports.values()].some(job=>job.source?.integrityVersion===2);if(hasReadProof!==(image.formatVersion===17))throw fault('unsupported_recovery_version');
const hasDocuments=[...imports.values()].some(job=>job.source?.kind==='native-document'),hasStreamingDocuments=[...imports.values()].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5);if(hasDocuments&&image.formatVersion!==(hasReadProof?17:hasStreamingDocuments?16:15)||image.formatVersion>=15&&!hasDocuments&&!hasReadProof)throw fault('unsupported_recovery_version');
for(const [id,entry]of importFiles(imports)){if(resources.has(id)||assetBindings.has(id))throw fault('import_file_identity_conflict',[id]);if((entry.file.retirement||imports.get(entry.jobId)?.source?.previousFiles?.length)&&image.formatVersion<4)throw fault('unsupported_recovery_version');}
// 上传资源、采纳binding、导入源和孤儿文件共享本地文件身份空间,任何交叉冲突均拒绝。
const orphanFiles=table<OrphanFile>(runtime.orphanFiles??[],(file,id)=>{validateOrphanFile(file,id);return freeze(file);});
if(orphanFiles.size&&image.formatVersion<5)throw fault('unsupported_recovery_version');for(const id of orphanFiles.keys())if(resources.has(id)||assetBindings.has(id)||importFiles(imports).has(id))throw fault('local_file_identity_conflict',[id]);
// 直到所有分表校验完成才组装一个候选 Root;不在这里启动 scheduler 或创建 view。
const restoredLedger: LedgerSnapshot = Object.freeze({ generation, durableGeneration, operations: records, requests });
const snapshot=Object.freeze({ revision: image.localRevision, rootRevision: image.rootRevision, document, history:restoredHistory, operations,
runtime: Object.freeze({ ...(runtime.loadingRead!==undefined?{loadingRead:Object.freeze({document:readDocument(object(runtime.loadingRead).document,registry),versions:table(object(runtime.loadingRead).versions,(value)=>integer(value,1))})}:{}), ...(runtime.loading!==undefined?{loading:freeze(runtime.loading as LoadingState)}:{}), orphanFiles, imports, recoveries, runs, resources, ...(assetBindings.size?{assetBindings}:{}), resourcePins, ledger: restoredLedger, acknowledged, versions, receipts, conflicts: table(runtime.conflicts, string) }),
});
// 永久清理的本地独有字节仍被 pin 是损坏状态,不能恢复成看似可用的图片引用。
if([...resources.values()].some(resource=>resource.retirement?.purgedAt!==undefined&&!resource.stored)){
const pins=resourceRetentionPins(snapshot,registry);for(const [id,resource]of resources)if(resource.retirement?.purgedAt!==undefined&&!resource.stored&&pins.pins.has(id))throw fault('corrupt_recovery_image');
}
if([...orphanFiles.values()].some(file=>file.retirement?.purgedAt!==undefined)){const pins=resourceRetentionPins(snapshot,registry);for(const [id,file]of orphanFiles)if(file.retirement?.purgedAt!==undefined&&pins.pins.has(id))throw fault('corrupt_recovery_image');}
// 最后把 loading、源捕获、ACK 与运行事实再联合校验,防止各表单独合法却组合矛盾。
validateLoadingRuntime(snapshot.runtime,registry);
return snapshot;
}
@@ -1,3 +1,7 @@
/**
* 只有明确历史证据支持的 undo/redo 恢复路径。先在私有双栈撤回原遍历,再重放用户保留的遍历。
* 不支持混合普通编辑或同项重复遍历时明确拒绝,不用猜测把当前 history 当作已正确恢复。
*/
import type { Document, HistoryState, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
import type { EngineSnapshot } from './contracts.ts';
import { invert } from '../canvas-core/document.ts';
@@ -5,21 +9,28 @@ import { planHistoryChanges } from '../canvas-core/history.ts';
import { fault, freeze, readMap } from '../canvas-core/values.ts';
/** Rewind only proved local stack moves, then replay the explicitly retained traversals. Never publish the temporary cursor. */
/** 输入原 pending、已审阅替代意图、保留选择与文档边界,返回新双栈/entry 候选而不发布。 */
export function replanTraversals(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap<Id,OperationIntent|undefined>,kept:ReadonlySet<Id>,boundaries:ReadonlyMap<Id,{before:Document;after:Document}>,registry:Registry):HistoryState {
const traversals=pending.filter(intent=>intent.changes.length);
if(traversals.some(intent=>!['undo','redo'].includes(intent.origin)))throw fault('recovery_history_mixed_traversal_not_supported');
// 由 entry.operationIds 反查所属历史项,而非从请求完成顺序推断 undo 栈。
const owner=new Map<Id,Id>();for(const entry of snapshot.history.entries.values())for(const id of entry.operationIds)owner.set(id,entry.id);
const seen=new Set<Id>(),undo=[...snapshot.history.undo],redo=[...snapshot.history.redo],entries=new Map(snapshot.history.entries);
/** 每条遍历必须有明确所属 entry,否则不能可靠移动栈。 */
function entryId(intent:OperationIntent){const id=owner.get(intent.id);if(!id)throw fault('recovery_history_traversal_evidence_missing',[intent.id]);return id;}
/** 只允许弹当前栈顶并压到另一栈;乱序恢复直接拒绝,不能从栈中间抽一项假装成功。 */
function move(source:Id[],destination:Id[],id:Id){if(source.at(-1)!==id)throw fault('recovery_history_traversal_order',[id]);source.pop();destination.push(id);}
// 逆逻辑顺序撤回已经发生的本地栈移动,得到用户审阅前的双栈起点。
for(const intent of [...traversals].reverse()){
const id=entryId(intent);if(seen.has(id))throw fault('recovery_history_repeated_traversal_not_supported',[id]);seen.add(id);
if(intent.origin==='undo')move(redo,undo,id);else move(undo,redo,id);
}
// 再按原逻辑顺序重放保留选择;每条都核对新的栈顶和正反向 changes 可执行性。
for(const intent of traversals){
const id=entryId(intent),entry=entries.get(id)!,source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo;
if(source.at(-1)!==id)throw fault('recovery_history_traversal_order',[id]);
const replacement=replacements.get(intent.id),boundary=boundaries.get(id)!;
// 保留但新基线已满足、没有 replacement 时,退休该项而非制造一条无法重做的假历史。
if(kept.has(intent.id)){
if(!replacement){source.pop();entries.delete(id);continue;} // No remaining local effect, retired with an audit rather than a fabricated redo.
const changes=intent.origin==='undo'?invert(replacement.changes):replacement.changes;
@@ -27,6 +38,7 @@ export function replanTraversals(snapshot:EngineSnapshot,pending:readonly Operat
catch{throw fault('recovery_history_traversal_conflict',[id]);}
move(source,destination,id);
entries.set(id,freeze({...entry,changes,operationIds:entry.operationIds.map(operationId=>operationId===intent.id?replacement.id:operationId)}));
// 丢弃该 pending 遍历不等于丢弃原 entry:保留经过校验的正向 changes,并移除旧操作关联。
}else{
const changes=intent.origin==='undo'?invert(intent.changes):intent.changes;
try{planHistoryChanges(boundary.before,intent.origin==='undo'?invert(changes):changes,registry);}