docs(runtime): explain mixed history recovery planning lifecycle

This commit is contained in:
2026-11-12 13:52:23 +08:00
parent a3d92f9cd6
commit c83641417b
@@ -15,7 +15,11 @@ 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 协作接口。 */
/** 一次同步恢复规划会话:从已验证 snapshot/pending 取事件,逆向条件回放历史元数据得到 baseline。
* 不逆向执行文档 changes 或访问服务;document 的 fresh 重规划由 project/rebaseIntent 处理。
* 调用方按事件顺序为每项 project→分配替代操作→accept,最后 finish→evidence,并同一个恢复 Root 采纳。
* snapshot/registry 沿用不可变输入;方法会改变私有游标/history,失败不自动回滚或封闭会话,应丢弃本次规划对象。
* limit 约束重建 user 贡献时的 undo 容量,不代表所有外部事件证据都可截断;无异步预算/网络/持久化。 */
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 以便核对后续取舍。
@@ -31,15 +35,19 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
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/容量淘汰或无效果移除的项。
// events 保存证据中的原始 before/after,history 是随取舍变化的新候选,不能混成同一份状态。
// next 指下个事件;touched 标本轮审阅项,retired 解释清 redo/容量淘汰/无效果删除;remap/sizeHistory 仅服务当前 project→accept。
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;不能按网络返回先后跳过或交换历史。 */
/** 只按下个 record 的 intent.id 检查位置,不逐字段比对外来 intent;协调器须传同快照中受信意图。
* 不能按网络返回顺序跳过/交换,adjustment 和已结算事件由 advance 先消费。 */
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 未变;结构性前缀必须可完整逆向执行,否则拒绝猜测部分历史。 */
/** 审阅已有分组项:纯字段按 target ID/代次/field 的最后 after 判断 fresh 是否还保留该贡献。
* 最终值/存在性匹配才保留该字段的原整条链,不将每条都压成一次最后值;不匹配就移除此字段贡献。
* geometry/path 的 owner 还须与原 ACK 一致;含 owner/端点或整记录的前缀必须可完整逆向规划。
* 只返回 changes,不移动文档;不在这里恢复被远端覆盖的旧字段值。 */
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))){
@@ -50,14 +58,17 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
}
try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[entry.id]);}return prefix;
}
/** 丢弃一个未保留的 user 贡献时,仍审查同 entry 中已确认的旧前缀,不能连旧贡献一起删除。 */
/** 无 replacement 的 user 贡献不追加新 changes;首次触及且还在 undo 的旧项才审阅原前缀。
* 空前缀退休并从两栈移除,非空则保留;无 entryId(history=false)、已 touched 或不在 undo 时不重复处理。 */
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和容量淘汰。 */
/** 以原事件 after 的 label/group 等元数据加入贡献,append 必须与原有分组/栈顶关系相容。
* 先清 redo 并标 retired;pending 首次触及旧项才审阅 fresh 前缀,随后追加本轮 changes/operationId。
* 新项压 undo,超过 limit 从栈底淘汰;已退休原组可按证据重新建立,不借旧 ID 静默改写任意已有项。 */
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]);
@@ -77,7 +88,9 @@ 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 不能越过它自动处理。 */
/** 消费目标 pending 之前(或 finish 时剩余)的 adjustment 与已有结果事件,逐个 recordStep/推进 next。
* 带 record 者不得是另一个 pending,resolution 只接受 applied/elided;不在此独立校验 phase/服务回执真实性。
* 默认尺寸、user、undo/redo 分别走专用逻辑,其它 origin 明确拒绝;不会重发这些已结算操作。 */
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;
@@ -90,7 +103,9 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
recordStep(before,event.transition.entryId,record?.intent.id);next++;
}
}
/** 对应已验证前后 entry 的变化位置提取代次映射,供其它历史目标跟随显式 restore。 */
/** 按原/新 entry changes 的对应索引提取同 ID 的 old incarnation→new incarnation Map。
* 不是任意两组 changes 的通用差异算法,依赖证据保证对应位置语义一致;不按资源/正文字符串猜身份。
* 返回纯 change 改写函数供其它历史目标跟随 restore,未映射代次保留原值。 */
function incarnationRemap(before:HistoryEntry,after:HistoryEntry){
const maps=new Map<Id,Map<number,number>>();
/** 仅同 ID 且代次确实改变时登记,不能把两个不同元素意外映为同一生命周期。 */
@@ -99,7 +114,8 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
/** 仅重写 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 是事实:只有当前审阅前缀仍表示完全相同效果才允许移动栈。 */
/** 已有 applied/elided 结果的遍历必须命中当前栈顶,且原事件前后变化与审阅后的正向效果精确对应。
* 只重建历史移动/关联并为其它项重映代次,不执行已结算文档变化,也不改旧 operation/request。 */
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]:[]);
@@ -110,7 +126,8 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
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/后续条件,不移动双栈;当前历史必须仍匹配原前置证据。 */
/** 已结算默认尺寸使用原 transition 的 entry changes 修正,要求该事件两栈不动、当前 changes/label/group 仍匹配。
* 不再次运行尺寸文档命令;只给所属创建项追加逻辑操作关联,其它被修正项保留各自关联。 */
function addConfirmedSize(intent: OperationIntent) {
validateCreationSizeEffect(intent.historyEffect);
const event = eventFor(intent), entries = new Map(history.entries);
@@ -123,7 +140,10 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
history = state(history.undo, history.redo, entries);
}
return{
/** 为当前 intent 计算文档候选,不立即移动对应新历史;accept 在替代操作身份确定后接续。 */
/** 先 advance 结算前置事件,再为当前 pending 返回 fresh 文档候选;自身对应的历史贡献等 accept。
* 尺寸专用 planner 暂存 sizeHistory;touched 遍历按审阅后的 entry 重新规划,其它 keep 意图走 rebaseIntent。
* 丢弃返回零文档变化但仍可能校验原遍历可行性;空 changes 直接返回,不消费事件游标。
* 此调用可能已经改变前置私有 history,不能当无状态 query,也不能并发交错不同项的 project/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 不发布旧自动尺寸。
@@ -150,7 +170,10 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
// 普通保留意图经显式审阅后重新规划;丢弃返回零文档变化,不自行伪造成功回执。
return keep?rebaseIntent(document,intent,context,registry):{document,changes:[] as readonly Change[]};
},
/** 接受 project 的取舍/替代操作,更新私有双栈并记 replay;每项完成后 next 单调前进。 */
/** 在协调器确定 replacement 后,按当前 project 结果更新私有历史、记 replay 并将 next 加一。
* replacement/keep/before/after 须与此前 project 配对;接口不独立比对该语义或自动生成新 ID/账本记录。
* user 按贡献维护组,尺寸采用暂存计划,keep 遍历有剩余效果才移到另一栈,否则退休该项。
* 不发布 Root、不持久化审计;调用失败后不要继续使用可能已部分推进的同一规划会话。 */
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) {
@@ -174,9 +197,12 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
}
recordStep(previous,id,replacement?.id);next++;
},
/** 消费剩余已确认事件后返回最终候选 history;未审阅 pending 仍会被 advance 拒绝。 */
/** 消费剩余已结算/调整事件并返回候选 history;未处理 pending 会拒绝,不静默跳过。
* 不设置 finished 锁或自动保存,协调器仍需整体校验并原子采纳文档/历史/账本。 */
finish(){advance();return history;},
/** 输出原 history→baseline 的 anchor 及有序增量,和本次 adoption 一起持久化以支持再次重开。 */
/** 完成 finish 后作为最后步骤输出证据:原 history→baseline 的 anchor,加本次有序元数据增量。
* 所有 transition 绑定 snapshot.rootRevision+1,不表示多次服务写入或已持久化成功。
* freeze 直接包含 replayEvents 数组,会冻结该会话的事件缓冲;提前调用后不能再向它追加规划事件。 */
evidence():HistoryReplay{return freeze({version:1 as const,anchor:describeHistoryTransition(snapshot.history,baseline,adoptionRevision,undefined,true)!,events:replayEvents});},
};
}