89 lines
7.1 KiB
TypeScript
89 lines
7.1 KiB
TypeScript
/**
|
||
* 已确认基线和待同步意图的纯协调工具。先验证 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;
|
||
}
|