76 lines
8.8 KiB
TypeScript
76 lines
8.8 KiB
TypeScript
/**
|
|
* 部分加载v5/v6允许闭合范围内导入/复制的准入与恢复校验。
|
|
* 源token、开始时逻辑sequence和必要canvas集合一起捕获,避免后来的未读树或新ACK改变原复制源含义。
|
|
*/
|
|
import type {CanvasRecord,Document,Id,Target} from '../canvas-core/types.ts';
|
|
import {fault,freeze,equal} from '../canvas-core/values.ts';
|
|
import type {EngineSnapshot} from './contracts.ts';
|
|
import {validateImportJob,type LoadingImportAdmission,type ImportJob} from './imports.ts';
|
|
import {applyChanges} from '../canvas-core/document.ts';
|
|
import {copyTopology} from './copy-snapshot-index.ts';
|
|
import {structuralRequestScope} from './loading.ts';
|
|
import {ownerReady} from './loading-structure.ts';
|
|
|
|
// 不可变文档的owner成员/入口子canvas关系索引,复用扫描结果但不另存第二份文档。
|
|
const relations=new WeakMap<Document,{members:Map<Id,Id[]>;children:Map<Id,Id[]>}>();
|
|
/** 构造直接成员和entry→子canvas映射,供选区DFS扩张使用,不从节点可见性推断拥有范围。 */
|
|
function index(document:Document){let value=relations.get(document);if(!value){const members=new Map<Id,Id[]>(),children=new Map<Id,Id[]>();for(const element of document.elements.values()){const ids=members.get(element.ownerCanvasId)??[];ids.push(element.id);members.set(element.ownerCanvasId,ids);}for(const canvas of document.canvases.values())if(canvas.parent){const ids=children.get(canvas.parent.entryElementId)??[];ids.push(canvas.id);children.set(canvas.parent.entryElementId,ids);}value={members,children};relations.set(document,value);}return value;}
|
|
/** The caller captures this proof with the same document that becomes source bytes. */
|
|
/** 无 loading 返回 undefined;只有 v5/v6+loadingRead 能生成准入,target 必须 ownerReady。
|
|
* selection 存在时以数组栈+copied Set 深扩张,补入头部声明却未发布的 child,避免误将未读分区当叶子。
|
|
* captured 元素还检查必要祖先;上级入边的 owner 也纳入 required,最终冻结去重排序范围与账本最大 sequence。
|
|
* 只派生证据,不复制字节/分配 ID/验证 policy;没有 selection 表示普通目标导入,不检查复制子树。 */
|
|
export function loadingImportAdmission(snapshot:EngineSnapshot,target:Id,selection?:readonly Target[]):LoadingImportAdmission|undefined {
|
|
const state=snapshot.runtime.loading;if(!state)return;
|
|
if((state.version!==5&&state.version!==6)||!snapshot.runtime.loadingRead)throw fault('model_loading');
|
|
const source=snapshot.runtime.loadingRead,document=snapshot.document,required=new Set<Id>([target]);
|
|
/** 每个必要canvas既加入证明,也必须当前ownerReady,不能只记名字就声称已经加载完整。 */
|
|
const requireScope=(id:Id)=>{required.add(id);if(!ownerReady(state,source,document,id))throw fault('loading_scope_incomplete',[id]);};requireScope(target);
|
|
if(selection){
|
|
if(!selection.length)throw fault('empty_copy_selection');const tables=index(document),pending=selection.map(target=>{const element=document.elements.get(target.id);if(!element||element.incarnation!==target.incarnation)throw fault('stale_incarnation',[target.id]);return target.id;}),copied=new Set<Id>(),canvases=new Map<Id,CanvasRecord>(document.canvases);
|
|
// Missing captured children cannot be mistaken for an empty/leaf selection.
|
|
// 完整sourceCanvases揭示未收到的子分区,不能把尚未到齐的F当成叶子,误复制空分区。
|
|
const missingChildren=new Map<Id,Id[]>();for(const canvas of state.sourceCanvases)if(canvas.parent&&!source.document.canvases.has(canvas.id)&&!document.canvases.has(canvas.id)){canvases.set(canvas.id,canvas);const ids=missingChildren.get(canvas.parent.entryElementId)??[];ids.push(canvas.id);missingChildren.set(canvas.parent.entryElementId,ids);}
|
|
// 显式stack去重深复制选区及全部后代,对每层直属owner检查闭合。
|
|
while(pending.length){const id=pending.pop()!;if(copied.has(id))continue;copied.add(id);const element=document.elements.get(id);if(!element)throw fault('loading_scope_incomplete',[id]);requireScope(element.ownerCanvasId);
|
|
for(const child of [...(tables.children.get(id)??[]),...(missingChildren.get(id)??[])]){requireScope(child);pending.push(...(tables.members.get(child)??[]));}
|
|
}
|
|
for(const id of copied){const element=document.elements.get(id)!;if(Object.hasOwn(state.elementOwners,id)){
|
|
let canvas=canvases.get(element.ownerCanvasId);const seen=new Set<Id>();while(canvas){if(seen.has(canvas.id))throw fault('invalid_model_topology');seen.add(canvas.id);requireScope(canvas.id);canvas=canvas.parent?canvases.get(canvas.parent.canvasId):undefined;}
|
|
}}
|
|
// Edges involving born objects are entirely local to this captured namespace.
|
|
for(const element of document.elements.values())if(element.structure==='connection'&&copied.has(element.target.elementId)&&!copied.has(element.source.elementId))requireScope(element.ownerCanvasId);
|
|
}
|
|
// 用当前最大逻辑sequence固定复制源时点,后续用户编辑不应倒灌成原源内容。
|
|
let operationSequence=0;for(const record of snapshot.runtime.ledger.operations.values())operationSequence=Math.max(operationSequence,record.intent.sequence);
|
|
return freeze({version:1 as const,sourceToken:state.sourceToken,operationSequence,requiredCanvasIds:[...required].sort()});
|
|
}
|
|
/** v5/v6 恢复/发布时逐个 validateImportJob,再核对项目、token、sequence 上界、target 包含与各范围准入。
|
|
* 只检查保存的 requiredCanvasIds,不在此重新展开 selection 或读取源字节;复制源重建由下一函数负责。
|
|
* 依赖外围已建立 loadingRead/账本身份,本函数不证明原 checkpoint 已落盘或获得当前服务器权限。 */
|
|
export function validateLoadingImportAdmissions(runtime:EngineSnapshot['runtime']):void {
|
|
const state=runtime.loading;if(!state||(state.version!==5&&state.version!==6))return;
|
|
let maxSequence=0;for(const record of runtime.ledger.operations.values())maxSequence=Math.max(maxSequence,record.intent.sequence);
|
|
for(const job of runtime.imports?.values()??[]){validateImportJob(job);if(job.projectId!==runtime.acknowledged.projectId)throw fault('invalid_loading_import',[job.id]);const proof=job.source?.loadingAdmission;if(!proof||proof.version!==1||proof.sourceToken!==state.sourceToken||proof.operationSequence>maxSequence||!proof.requiredCanvasIds.includes(job.source!.targetCanvasId)||proof.requiredCanvasIds.some(id=>!structuralRequestScope(runtime,runtime.loadingRead!,id)))throw fault('invalid_loading_import',[job.id]);}
|
|
}
|
|
|
|
/** Revalidate the frozen copy topology before planning or resuming its source.
|
|
* Later source chunks may add unrelated owners, so compare only the closed cut. */
|
|
/** 从原捕获源重放 sequence≤准入时点的全部意图 changes(不按 phase 过滤,conditional=false)重建旧逻辑切点。
|
|
* 用 preparation/plan 中 selection 重算 required 范围并逐 owner 比较 canvas 与 copyTopology,忽略无关后到范围。
|
|
* copyTopology 保留身份/owner/端点/几何及表现尺度,省略正文等组件内容和边 path;这不是完整 payload 一致性证明。
|
|
* 不计算 SHA、不执行完整文档/权限校验,也不发布重建文档;源字节完整性由快照/导入证据链负责。 */
|
|
export function validateLoadingCopySource(snapshot:EngineSnapshot,job:ImportJob,document:Document):void {
|
|
const state=snapshot.runtime.loading,proof=job.source?.loadingAdmission;if(!state||(state.version!==5&&state.version!==6)||!proof)return;
|
|
const source=snapshot.runtime.loadingRead!;let expected=source.document;
|
|
// 原captured源叠当时已有的逻辑变化重建源时点,不把当前working当成冻结复制快照。
|
|
for(const record of [...snapshot.runtime.ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence))if(record.intent.sequence<=proof.operationSequence)expected=applyChanges(expected,record.intent.changes,false);
|
|
const selection=job.preparation?.selection??job.source?.plan?.selection;if(!selection)throw fault('invalid_loading_import',[job.id]);
|
|
const required=loadingImportAdmission({...snapshot,document:expected},job.source!.targetCanvasId,selection)!;
|
|
if(!equal(required.requiredCanvasIds,proof.requiredCanvasIds))throw fault('loading_copy_scope_changed',[job.id]);
|
|
// 逐owner比较canvas与copyTopology;普通payload并非这里完整性证明,源字节/摘要由其它导入层校验。
|
|
for(const id of proof.requiredCanvasIds){if(!equal(expected.canvases.get(id),document.canvases.get(id)))throw fault('loading_copy_scope_changed',[id]);
|
|
const wanted=Object.fromEntries([...expected.elements.values()].filter(element=>element.ownerCanvasId===id).map(element=>[element.id,copyTopology(element)])),actual=Object.fromEntries([...document.elements.values()].filter(element=>element.ownerCanvasId===id).map(element=>[element.id,copyTopology(element)]));if(!equal(wanted,actual))throw fault('loading_copy_scope_changed',[id]);
|
|
}
|
|
}
|