From af8e2fe76b65798d3a63c78be86cce5bdd30f66f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 00:13:52 +0800 Subject: [PATCH] docs(canvas): explain recovery encoding and history replay evidence --- .../canvas-runtime/history-event-source.ts | 14 +++++ .../history-journal-recovery.ts | 38 +++++++++++++ src/lib/canvas-runtime/history-replay.ts | 9 ++++ src/lib/canvas-runtime/journal-delta.ts | 11 ++++ src/lib/canvas-runtime/recovery-encoding.ts | 39 ++++++++++++++ src/lib/canvas-runtime/recovery-json.ts | 11 ++++ src/lib/canvas-runtime/recovery.ts | 54 ++++++++++++++++++- src/lib/canvas-runtime/traversal-recovery.ts | 12 +++++ 8 files changed, 187 insertions(+), 1 deletion(-) diff --git a/src/lib/canvas-runtime/history-event-source.ts b/src/lib/canvas-runtime/history-event-source.ts index 7b45c5a..fd3fd5e 100644 --- a/src/lib/canvas-runtime/history-event-source.ts +++ b/src/lib/canvas-runtime/history-event-source.ts @@ -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(),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;iother.baseRootRevision+1>revision))throw fault('recovery_history_adoption_stale',[audit.id]); + // adjustment 不需要服务操作;operation 虚拟事件必须能定位真实账本记录并具有可用解释器。 const witnessed=new Set(),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}}; diff --git a/src/lib/canvas-runtime/history-journal-recovery.ts b/src/lib/canvas-runtime/history-journal-recovery.ts index 7b093b0..4f5acd1 100644 --- a/src/lib/canvas-runtime/history-journal-recovery.ts +++ b/src/lib/canvas-runtime/history-journal-recovery.ts @@ -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(),retired=new Set();let remap:((change:Change)=>Change)|undefined; let sizeHistory: HistoryState | undefined; + /** 捕获双栈数组和 entry 表成员为新只读 history,不暴露私有可变容器。 */ const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap)=>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();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)[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>(); + /** 仅同 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=(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});}, }; } diff --git a/src/lib/canvas-runtime/history-replay.ts b/src/lib/canvas-runtime/history-replay.ts index ffc9930..3d34009 100644 --- a/src/lib/canvas-runtime/history-replay.ts +++ b/src/lib/canvas-runtime/history-replay.ts @@ -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; 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(); for(const value of raw.events){if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const event=value as Record;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)); } diff --git a/src/lib/canvas-runtime/journal-delta.ts b/src/lib/canvas-runtime/journal-delta.ts index d492cca..39cfc65 100644 --- a/src/lib/canvas-runtime/journal-delta.ts +++ b/src/lib/canvas-runtime/journal-delta.ts @@ -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)[key],depth+1),enumerable:true,writable:true,configurable:true});return copy; } result=replace(result,0); diff --git a/src/lib/canvas-runtime/recovery-encoding.ts b/src/lib/canvas-runtime/recovery-encoding.ts index 2d8b27d..d6d91bd 100644 --- a/src/lib/canvas-runtime/recovery-encoding.ts +++ b/src/lib/canvas-runtime/recovery-encoding.ts @@ -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(),sealed=new WeakSet(),encodedSizes=new WeakMap(),imageSizes=new WeakMap(); +/** 计算 UTF-8 字节数(代理对计4字节);调用时多为 JSON.stringify 后的已转义字符串。 */ function utf8Length(text:string){let bytes=0;for(let i=0;i=0xd800&&c<=0xdbff&&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(); + /** 按原 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={};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(); 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 { validateHeader(formatVersion,rootRevision,localRevision); const {plan,normalize}=prepareEncoding(payload); const scheduler=(globalThis as typeof globalThis & {scheduler?:{yield?:()=>Promise}}).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,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);} diff --git a/src/lib/canvas-runtime/recovery-json.ts b/src/lib/canvas-runtime/recovery-json.ts index b57a060..c03180f 100644 --- a/src/lib/canvas-runtime/recovery-json.ts +++ b/src/lib/canvas-runtime/recovery-json.ts @@ -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{ if(value.length<=4096){yield JSON.stringify(value);return;} yield '"'; @@ -10,6 +16,7 @@ function* stringTokens(value:string):Generator{ yield '"'; } /** Only traverse codec-owned normalized JSON. Small owned subtrees use native stringify. */ +/** 保留数组/对象原顺序,显式输出分隔符;≤16KiB 已知子树直接 stringify,降低 token 分配开销。 */ function* tokens(value:unknown):Generator{ 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{ 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{ 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?.(); diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index b93bceb..cd39898 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -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(); +/** 可选表缺省的共享空数组,只表示没有表成员,不表示其数据已完成业务校验。 */ const noEntries=dataArray([]); +/** 把 Map 编成有序元组数组;自有读视图可复用外壳,其 value 是否可复用由 codec 另查。 */ function mapValue(map?:ReadonlyMap):readonly (readonly [K,V])[]{ if(!map)return noEntries; if(!isOwnedMapView(map))return [...map]; @@ -35,17 +44,24 @@ function mapValue(map?:ReadonlyMap):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>(); +/** 工厂文档可复用成员外壳;未知来源文档重新枚举,防止冻结外壳掩盖可变 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 { return sealRecoveryImageAsync(...recoveryInput(snapshot),options); } +/** 解码边界的未验证对象字段表,后续每个字段仍需具体校验。 */ type ObjectValue = Record; +/** 要求非 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(value: unknown, decode: (entry: unknown, id: Id) => T): ReadonlyMap { const result = new Map(); 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):HistoryState { const entries=table(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(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(), dependents = new Map(), 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(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(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(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(); + // 审计备份、选择、原/替代操作和 history replay 是一个闭合证明,不能只保留一句 keep-local 标签。 const recoveries=table(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(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(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(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; } diff --git a/src/lib/canvas-runtime/traversal-recovery.ts b/src/lib/canvas-runtime/traversal-recovery.ts index 9fbb9bd..70e9925 100644 --- a/src/lib/canvas-runtime/traversal-recovery.ts +++ b/src/lib/canvas-runtime/traversal-recovery.ts @@ -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,kept:ReadonlySet,boundaries:ReadonlyMap,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();for(const entry of snapshot.history.entries.values())for(const id of entry.operationIds)owner.set(id,entry.id); const seen=new Set(),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);}