Files
Endoleap/src/lib/canvas-runtime/reconcile.ts
T

89 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 已确认基线和待同步意图的纯协调工具。先验证 canonical 文档/版本不可倒退,
* 再按原逻辑次序条件回放未解决的文档意图,形成 working 候选;不把旧 ACK 整份覆盖当前编辑。
* 这里只计算/校验,最终 document/runtime/ledger 的原子发布由 engine/editor 负责。
*/
import { withLocalLifecycles } from './elision.ts';
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
import type { Document, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
import { replayChanges, validateDocument } from '../canvas-core/document.ts';
import { equal, fault } from '../canvas-core/values.ts';
import type { LedgerSnapshot } from './ledger.ts';
/** A complete read cannot rewind known scopes or change an unchanged-version canvas. */
/** 校验完整 fresh read:同项目、合法文档、活 canvas 版本齐全、已知版本和墓碑均不可倒退。
* 某 canvas 版本没变时,其记录/直属元素必须相同;全向量没变时墓碑集合也不能偷偷改变。
* 只校验模型一致性/单调性,不证明响应身份或读取来自何服务;传输认证与实际完整覆盖由端口保证。 */
export function validateCanonicalRead(previous: Document, previousVersions: ReadonlyMap<Id,number>, next: Document, versions: ReadonlyMap<Id,number>, registry: Registry) {
if(next.projectId!==previous.projectId)throw fault('project_mismatch');
validateDocument(next,registry);
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]);
// 活 canvas 必须有版本,但不要求版本表只含活项;已移除 scope 的已知水位也可继续保留。
for(const canvas of next.canvases.keys())if(!versions.has(canvas))throw fault('missing_server_version',[canvas]);
// 记录服务声明“版本未变”的 owner,随后双向检查元素,既防遗漏删除也防夹带新增。
const unchanged=new Set<Id>();
for(const [id,version]of previousVersions){
if((versions.get(id)??-1)<version)throw fault('stale_canonical_version',[id]);
if(versions.get(id)!==version)continue;
if(!equal(previous.canvases.get(id),next.canvases.get(id)))throw fault('canonical_version_mismatch',[id]);
unchanged.add(id);
}
for(const [id,element]of previous.elements)if(unchanged.has(element.ownerCanvasId)&&!equal(element,next.elements.get(id)))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);
for(const [id,element]of next.elements)if(unchanged.has(element.ownerCanvasId)&&!equal(element,previous.elements.get(id)))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);
for(const [id,incarnation]of previous.tombstones)if((next.tombstones.get(id)??0)<incarnation)throw fault('stale_canonical_lifecycle',[id]);
if(versions.size===previousVersions.size&&[...versions].every(([id,version])=>previousVersions.get(id)===version)&&
(previous.tombstones.size!==next.tombstones.size||[...previous.tombstones].some(([id,incarnation])=>next.tombstones.get(id)!==incarnation)))throw fault('canonical_version_mismatch');
}
/** The footprint follows ownership; geometry does not decide which canvas owns an object. */
/** 计算跨 canvas footprint:声明了非空 intent.canvasIds 就直接沿用,不以是否已冻结为条件。
* 否则从变化收集新旧 owner/父级/端点归属;上传和 run.cancel 总是返回空文档参与者。
* 不根据画框相交或 run 的 boardId/action 自动猜参与者;ID 有效性与编译覆盖另由边界核对。 */
export function operationCanvases(intent: OperationIntent, document: Document, after:Document=document): readonly Id[] {
if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[];
// 已声明范围按原引用/顺序返回,不在此排序去重;仅下方推导路径用 Set 并排序。
if (intent.canvasIds?.length) return intent.canvasIds;
// 字段变化不能只查 after;重挂/改端点时旧 owner 也参与版本条件和占用。
const ids = new Set<Id>();
for (const change of intent.changes) {
if (change.kind === 'canvas') {
ids.add(change.id);
if (change.before?.parent) ids.add(change.before.parent.canvasId);
if (change.after?.parent) ids.add(change.after.parent.canvasId);
} else if (change.kind === 'element') {
if (change.before) ids.add(change.before.ownerCanvasId);
if (change.after) ids.add(change.after.ownerCanvasId);
for(const record of [change.before,change.after])if(record?.structure==='connection')for(const endpoint of [record.source,record.target]){const owner=after.elements.get(endpoint.elementId)?.ownerCanvasId??document.elements.get(endpoint.elementId)?.ownerCanvasId;if(owner)ids.add(owner);}
} else {
const owner = document.elements.get(change.target.id)?.ownerCanvasId;
if (owner) ids.add(owner);
const nextOwner=after.elements.get(change.target.id)?.ownerCanvasId;if(nextOwner)ids.add(nextOwner);
if(change.field==='source'||change.field==='target')for(const endpoint of [change.before,change.after])if(endpoint&&typeof endpoint==='object'&&!Array.isArray(endpoint)&&typeof endpoint.elementId==='string'){for(const model of [document,after]){const owner=model.elements.get(endpoint.elementId)?.ownerCanvasId;if(owner)ids.add(owner);}}
if (change.field === 'ownerCanvasId') { ids.add(change.before as Id); ids.add(change.after as Id); }
}
}
if (!ids.size) throw fault('missing_participants', [intent.id]);
return [...ids].sort();
}
/** Replay once, by logical sequence. A receipt can already contain a desired field value. */
/** 将本地生命周期水位叠到 ACK 副本,再按 sequence 回放 document.change/run.apply。
* 底层按同 incarnation/字段存在性判断已满足 after,并检查 field owner 范围;条件冲突仍拒绝。
* 不改写意图/冻结请求,不移动 history;historyEffect 只在此验证形状,不重新执行尺寸历史规划。 */
export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document {
/** 已 resolved 项不重放;未冻结、冻结或 blocked 的未解决文档意图仍按原事实参与重建。 */
function* pending(){
for(const record of [...ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){
if(record.phase==='resolved'||!['document.change','run.apply'].includes(record.intent.operationType))continue;
// 只筛 document.change/run.apply 的变化;上传、run.start/cancel 不因存在 changes 字段就在这里执行。
if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);
yield {changes:record.intent.changes,canvasIds:record.intent.canvasIds};
}
}
// 本地抵消可能从未进入服务器,水位只加到 working 候选,不能冒充服务器已确认墓碑。
const document=replayChanges(withLocalLifecycles(base,ledger),pending());
// 全部回放成功后检查最终工作文档;任何异常让调用者丢弃这份候选,不在此处理冲突 UI 或覆盖 ACK。
validateDocument(document,registry);
return document;
}