docs(canvas): explain resumable import assembly and batch ownership

This commit is contained in:
2026-11-12 00:57:17 +08:00
parent c59850e7a3
commit 28f1a74d40
+87 -3
View File
@@ -871,20 +871,26 @@ export async function openProjectEngine(options: EngineOptions) {
const result=editor.updateRuntime(previous=>{const ledger=createLedger(previous.ledger);ledger.add(freeze(intent),{});return Object.freeze({...previous,ledger:ledger.getSnapshot()});},root.rootRevision);
if(result.status==='rejected')return result;queueMicrotask(()=>{if(!closing&&!closed)scheduler.notify([id]);});return{status:'applied',revision:root.revision,operationIds:[id]};
}
/** 导入允许渐进加载 v5+ 的已认证范围;仍复用一般写入的身份、租约和恢复会话护栏。 */
function assertImportWritable(){assertWritable(false);const loading=editor.getSnapshot().runtime.loading;if(loading&&loading.version<5)throw fault('model_loading');}
/** 从唯一根读取当前作业,不以 runner 捕获的旧对象代替可恢复事实。 */
function getImport(id:Id){const job=editor.getSnapshot().runtime.imports?.get(id);if(!job)throw fault('import_missing',[id]);return job;}
/** 仅发布作业元数据;generation 乐观校验防止旧异步结果覆盖暂停/取消/替换后的作业。
* integrityOwner 只在本次同步更新内允许该 job 推进校验信息,finally 撤销权限。 */
function publishImport(job:ImportJob,expectedGeneration:number){
assertImportWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
integrityOwner=job.id;try{const result=editor.updateRuntime(previous=>{const imports=new Map(previous.imports??[]);if(imports.get(job.id)?.generation!==expectedGeneration)throw fault('stale_import_job');imports.set(job.id,job.version===3?job:freeze({...job,version:3 as const}));return Object.freeze({...previous,imports:readMap(imports)});});if(result.status==='rejected')throw fault(result.code,result.targets);}finally{integrityOwner=undefined;}
}
// These records are newly constructed by the engine, including source and
// preparation envelopes. Shape ownership never asserts ownership of children.
/** 为新建检查点声明各层记录容器的所有权,便于增量恢复编码复用。
* dataRecord/dataArray 不代表所有子数据也归这里所有,不跳过后续结构与内容校验。 */
function importCheckpoint(job:ImportJob):ImportJob {
return dataRecord(freeze({...job,
...(job.source?{source:dataRecord(freeze({...job.source}))}:{}),
...(job.preparation?{preparation:dataRecord(freeze({...job.preparation,allocationIds:dataArray(freeze(job.preparation.allocationIds))}))}:{}),
}));
}
/** 将当前分块读取证明附入检查点;SHA/校验错误以根中最新值为准,
* 防止前台解析的旧 job 覆盖后台全文件验证刚发布的结果。 */
function checkpointImportSource(job:ImportJob):ImportJob{
if(job.source?.integrityVersion!==2)return job;
const context=proofReaders.get(job.id),current=editor.getSnapshot().runtime.imports?.get(job.id)?.source;
@@ -892,47 +898,68 @@ export async function openProjectEngine(options: EngineOptions) {
const {sha256:_,integrityError:__,...source}=job.source;
return importCheckpoint({...job,source:{...source,readProof:context.reader.getProof(),...(current?.sha256?{sha256:current.sha256}:{}),...(current?.integrityError?{integrityError:current.integrityError}:{})}});
}
/** 文件校验失败增加 generation,保留已取消/部分完成状态,其余转为暂停。
* 错误写入源证据,既有已提交内容保留;短暂 hold 触发 runner 的暂停边界,不持有永久暂停租约。 */
function failImportVerification(job:ImportJob,error:unknown){
const code=(error as {code?:string}).code??'import_source_verification_failed';
publishImport(importCheckpoint({...job,generation:job.generation+1,status:['cancelled','partial'].includes(job.status)?job.status:'paused',source:{...job.source!,integrityError:code},error:code}),job.generation);
const release=importRunners.get(job.id)?.hold();release?.();report(error);
}
/** 每 job 单飞的后台完整性校验:先核对已有分块证明,再计算整文件 SHA。
* 中止/关闭/全局 hold 时不把取消当损坏;真正失败记录证据,成功保存 seal 后才允许相应同步。 */
function verifyImportSource(id:Id,context:{fileId:Id;bytes:Blob;reader:ReturnType<typeof createImportReadProof>}):Promise<void>{
// 已中止的旧实例要先结束,再确认上下文仍有效才重建;不能并行验证两个同名来源。
const active=verificationTasks.get(id);if(active)return active.controller.signal.aborted?active.promise.then(()=>{if(!closing&&!closed&&!importHolds.size&&proofReaders.get(id)===context&&sourceNeedsSeal(getImport(id)))return verifyImportSource(id,context);}):active.promise;
const admitted=getImport(id),controller=new AbortController(),signal=AbortSignal.any([controller.signal,digestLifecycle.signal]);
/** 每次跨 await 后按 fingerprint、fileId、reader 身份复查,不把旧文件结果写给新作业。 */
const current=()=>{signal.throwIfAborted();if(closing||closed||importHolds.size)throw fault('import_aborted');const job=getImport(id);if(job.sourceFingerprint!==admitted.sourceFingerprint||job.source?.fileId!==context.fileId||proofReaders.get(id)!==context)throw fault('import_identity_changed');return job;};
const promise=(async()=>{
let sha256:string;
try{await context.reader.verify(signal);sha256=await abortableFileRead(sourceDigest(context.bytes,signal),signal);const job=current();if(job.source?.sha256&&job.source.sha256!==sha256)throw fault('import_source_changed');}
catch(error){if(signal.aborted||closing||closed||importHolds.size)return;failImportVerification(current(),error);try{await persist();}catch{}return;}
// 已取消且从未提交内容就无需发布 seal;有已提交前缀时仍需完成其来源证据。
const job=current();if(job.status==='cancelled'&&!job.committedSourceKeys.length)return;const{integrityError:oldError,...source}=job.source!,next={...job,source:{...source,sha256,readProof:context.reader.getProof()}};if(oldError&&next.error===oldError)delete next.error;
publishImport(importCheckpoint(next),job.generation);await persist();
})().catch(error=>{if(!signal.aborted&&!closing&&!closed)report(error);}).finally(()=>{if(verificationTasks.get(id)?.controller===controller)verificationTasks.delete(id);});
verificationTasks.set(id,{controller,promise});return promise;
}
/** 重建读取器前先中止并等验证结束、关闭 runner,最后释放分块证明缓存;持久作业不删除。 */
async function resetImportReader(id:Id){
const active=verificationTasks.get(id);active?.controller.abort();if(active)await active.promise;
const runner=importRunners.get(id);if(runner){await runner.close();importRunners.delete(id);}
proofReaders.get(id)?.reader.close();proofReaders.delete(id);
}
/** 将是否仍欠来源校验的协议判断交给 imports 模块,终态也可能仍需 seal。 */
function sourceNeedsSeal(job:ImportJob){return importNeedsSeal(job);}
// 暂停期间被中断、尚未建立 runner 的任务要记下,释放 hold 后才能重新装配。
const deferredImportLoads=new Set<Id>();
/** 可嵌套全局导入暂停:中止准备/验证,给已装配 runner 各加一个 hold。
* 释放只撤销本 token 的租约,再按根中状态启动需要恢复的任务;不把业务 status 改成 cancelled。 */
function holdImports(){for(const id of importLoads.keys())if(!importRunners.has(id))deferredImportLoads.add(id);const token=Symbol(),releases=new Map<Id,()=>void>();importHolds.set(token,releases);for(const controller of preparationControllers.values())controller.abort();for(const {controller}of verificationTasks.values())controller.abort();for(const[id,runner]of importRunners)releases.set(id,runner.hold());return()=>{if(!importHolds.delete(token))return;for(const release of releases.values())release();if(!closing&&!closed)for(const job of editor.getSnapshot().runtime.imports?.values()??[])if(sourceNeedsSeal(job)||['running','preparing'].includes(job.status)&&!importRunners.has(job.id))void loadImport(job.id);};}
/** 从固定复制快照恢复规划。规划版本决定生成可流式读取的计划,或旧版 NDJSON 文件。
* 期间不断保存 ID 分配/步骤检查点,保证恢复后新元素身份稳定;此阶段还未向画布加入元素。 */
async function materializeCopy(id:Id,bytes:Blob,controller:AbortController){
const admitted=getImport(id),preparation=admitted.preparation;
if(!preparation||admitted.source?.kind!=='duplicate-snapshot')throw fault('invalid_copy_preparation');
/** 复制规划只接受原 fingerprint 且仍 preparing 的作业;关闭、hold、取消均使旧结果失效。 */
const check=()=>{const job=getImport(id);if(controller.signal.aborted||closing||closed||importHolds.size||job.status!=='preparing'||job.sourceFingerprint!==admitted.sourceFingerprint)throw fault('copy_aborted');return job;};
// 先保存快照作业再规划;v2 索引快照可按需读取,旧快照走完整解码。
await persist();check();const indexed=admitted.source.snapshotVersion===2?await openIndexedCopySnapshot(bytes,options.scope.projectId,options.registry,controller.signal):undefined,document=indexed?.document??await decodeCopySnapshot(bytes,options.scope.projectId,options.registry,controller.signal);
// 渐进模型还要证明复制快照对应合法来源范围;类型/复制声明变化则拒绝续用旧规划器。
validateLoadingCopySource(editor.getSnapshot(),admitted,document);
if(copyRegistrySignature(document,options.registry)!==preparation.registrySignature)throw fault('copy_interpreter_changed');
// 新规划器持久化拓扑与稳定 ID,不先展开所有复制数据为另一份 NDJSON。
if(preparation.version===3||preparation.version===4){
const state=await prepareDuplicateStream(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId},{sourceToken:admitted.source.sha256!,schemaToken:preparation.registrySignature,checkpoint:preparation.planner,topologyOnly:!!indexed},{signal:controller.signal,checkpointIntervalMs:50,maxStepsPerCheckpoint:8192,checkpoint:async state=>{
// 只采纳向前推进的 planner 检查点,保存完成后再继续规划,避免恢复丢失已分配身份。
const job=check(),previous=job.preparation!;if(previous.planner&&state.steps<=previous.steps)return;
publishImport(importCheckpoint({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check();
}}),job=check();assertNativeImportTarget(job,editor.getSnapshot().document);
// 将旧 ID → 新 ID 和计划一起发布为 running;移除 preparation 后由普通导入 runner 逐批落图。
const stableIds=dataRecord(Object.fromEntries([...state.elementIds.map(([old,id])=>[`element:${old}`,id]),...state.canvasIds.map(([old,id])=>[`canvas:${old}`,id])])),next={...job,generation:job.generation+1,parserVersion:2,status:'running' as const,stableIds,source:{...job.source!,kind:'duplicate-plan' as const,plan:state,copy:dataRecord({sourceRevision:job.source!.copy!.sourceRevision,roots:dataArray(state.roots.map(old=>stableIds[`element:${old}`]))})}};delete next.preparation;delete next.error;
publishImport(importCheckpoint(next),job.generation);return{job:getImport(id),bytes,document,indexed};
}
// 兼容旧规划协议:v2 恢复 planner;v1 按原分配顺序重放 IDs,再追加新的分配。
const allocationIds=[...preparation.allocationIds];let cursor=0;
const plan=preparation.version===2?await planDuplicateResumable(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId},{sourceToken:admitted.source.sha256!,schemaToken:preparation.registrySignature,checkpoint:preparation.planner},{signal:controller.signal,checkpoint:async state=>{
const job=check(),previous=job.preparation!;if(previous.planner&&state.steps<=previous.steps)return;
@@ -942,121 +969,178 @@ export async function openProjectEngine(options: EngineOptions) {
if(steps<=previous.steps&&allocationIds.length===previous.allocationIds.length)return;
publishImport(importCheckpoint({...job,generation:job.generation+1,preparation:{...previous,steps:Math.max(previous.steps,steps),allocationIds:[...allocationIds]}}),job.generation);await persist();check();
}});
// 旧解释器若消耗的 ID 数量变了,原分配顺序已不可信,不能静默生成另一份复制结果。
check();if(preparation.version===1&&cursor!==allocationIds.length)throw fault('copy_interpreter_changed');
// 旧版计划编码成固定 NDJSON 并算 SHA;异步完成后再次校验目标入口及当前业务策略。
const encoded=await encodeDuplicateSource(document,options.registry,plan,controller.signal),sha256=await sourceDigest(encoded,controller.signal),job=check();assertImportWritable();assertNativeImportTarget(job,editor.getSnapshot().document);
const permission=options.policy?.({type:'changes.apply',changes:plan.changes},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
const fileId=createId(),runtime=editor.getSnapshot().runtime;if(!fileId||fileWrites.has(fileId)||runtime.resources.has(fileId)||[...(runtime.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict');
const stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([old,id])=>[`element:${old}`,id]),...Object.entries(plan.canvasIds).map(([old,id])=>[`canvas:${old}`,id])]);
// 新源替换旧快照时把原文件保留在 previousFiles,后续清理仍需保留期与引用证明。
const next={...job,generation:job.generation+1,status:'running' as const,stableIds,sourceFingerprint:`local-file:${fileId}:${encoded.size}`,source:{...job.source!,previousFiles:[...(job.source!.previousFiles??[]),{fileId:job.source!.fileId,byteLength:job.source!.byteLength,...(job.source!.sha256?{sha256:job.source!.sha256}:{})}],kind:'duplicate-ndjson' as const,fileId,byteLength:encoded.size,sha256,integrityVersion:1 as const,copy:{sourceRevision:job.source!.copy!.sourceRevision,roots:plan.roots}}};delete next.preparation;delete next.error;
// 先把字节放入待保存集再发布引用它的根,发布失败则撤回本次字节暂存。
fileWrites.set(fileId,encoded);try{publishImport(importCheckpoint(next),job.generation);}catch(error){fileWrites.delete(fileId);throw error;}
return{job:getImport(id),bytes:encoded,document:undefined,indexed:undefined};
}
/** 按 job 装配或复用导入执行器。验证来源、选择协议解析器、准备依赖与资源,再逐批提交。
* importLoads 是装配任务的单飞表,importRunners 是执行器表,两者都从持久 job 派生。 */
function loadImport(id:Id):Promise<void>{
// 重复调用共享正在装配的任务;已有 runner 时只补 seal/继续运行,不重复解析和提交。
if(importLoads.has(id))return importLoads.get(id)!;
if(importRunners.has(id)){const context=proofReaders.get(id);if(context&&sourceNeedsSeal(getImport(id)))void verifyImportSource(id,context);if(getImport(id).status==='running')void importRunners.get(id)!.start();return Promise.resolve();}
deferredImportLoads.delete(id);
// 复制物化可能合法替换源指纹;失败收尾仅可修改这个装配流程最终对应的来源。
let expectedFingerprint=getImport(id).sourceFingerprint;
const preparationController=new AbortController();if(getImport(id).source?.integrityVersion===2||['native-document','duplicate-snapshot','duplicate-plan'].includes(getImport(id).source?.kind??''))preparationControllers.set(id,preparationController);
const task=(async()=>{
// kind、parserVersion 与 integrityVersion 是独立协议轴;未知组合必须保留任务并停止解释。
let job=getImport(id),source=job.source;if(!source||!['native-document','native-json-array','native-ndjson','duplicate-ndjson','duplicate-snapshot','duplicate-plan'].includes(source.kind)||(source.kind==='native-document'?![4,5].includes(job.parserVersion):job.parserVersion!==(source.kind==='native-json-array'?3:source.kind==='duplicate-plan'?2:1)))throw fault('import_interpreter_unavailable');
let bytes=fileWrites.get(source.fileId)??await abortableFileRead(options.storage.readFile(leader,source.fileId),preparationController.signal);
if(closing||closed)return;
// 读文件期间可能已取消/完成/换源;终态仅在仍欠分块协议 seal 时继续校验,不再加入内容。
const latest=getImport(id);if(latest.sourceFingerprint!==job.sourceFingerprint||latest.source?.fileId!==source.fileId||['completed','partial','cancelled'].includes(latest.status)&&(source.integrityVersion!==2||!sourceNeedsSeal(latest)))return;
if(!bytes)throw fault('import_source_unavailable');if(bytes.size!==source.byteLength)throw fault('import_source_changed');
let readBytes:ReturnType<typeof createImportReadProof>['port']|undefined;
// 分块协议可在按需验证读取的同时做全文件校验;已有 SHA 则先确认整文件匹配再继续。
if(source.integrityVersion===2){
let context=proofReaders.get(id);if(!context||context.fileId!==source.fileId){context={fileId:source.fileId,bytes,reader:createImportReadProof({bytes,fileId:source.fileId,proof:source.readProof})};proofReaders.set(id,context);}
// 恢复先重验已保存分块证明;后续解析读口同样经过 reader,不能直接绕过它读取任意字节。
await context.reader.verify(preparationController.signal);const checking=verifyImportSource(id,context);if(source.sha256)await checking;
job=getImport(id);source=job.source!;if(preparationController.signal.aborted||['completed','partial','cancelled','paused'].includes(job.status)||importHolds.size)return;readBytes=context.reader.port;
}else{
// 旧完整性协议在开始解析前计算全文件摘要,不用新的分块语义猜测旧格式。
if(source.integrityVersion!==1)throw fault('import_source_integrity_unavailable');
const sha256=await sourceDigest(bytes,preparationController.signal);
if(closing||closed)return;
// 整文件散列可能较久,严格对照开始读取时的 generation;用户控制操作优先于迟到结果。
const verified=getImport(id);if(verified.generation!==latest.generation||verified.sourceFingerprint!==job.sourceFingerprint||verified.source?.fileId!==source.fileId||['completed','partial','cancelled','paused'].includes(verified.status))return;
if(importHolds.size){deferredImportLoads.add(id);return;}
if(source.sha256&&source.sha256!==sha256)throw fault('import_source_changed');
if(!source.sha256){publishImport(importCheckpoint({...verified,generation:verified.generation+1,source:{...source,sha256}}),verified.generation);job=getImport(id);source=job.source!;}else job=verified;
}
// 复制源先完成规划或打开索引;普通原生源直接选择对应文档/JSON 数组/逐行解析器。
let copyDocument:Document|undefined,indexed:IndexedCopySnapshot|undefined;
if(source.kind==='duplicate-snapshot'){if(preparationController.signal.aborted||importHolds.size||latest.status!=='preparing')return;const materialized=await materializeCopy(id,bytes,preparationController);job=materialized.job;source=job.source!;bytes=materialized.bytes;copyDocument=materialized.document;indexed=materialized.indexed;expectedFingerprint=job.sourceFingerprint;}
if(source.kind==='duplicate-plan'&&source.snapshotVersion===2){indexed??=await openIndexedCopySnapshot(bytes,options.scope.projectId,options.registry,preparationController.signal);copyDocument=indexed.document;}
if(source.kind==='duplicate-plan'&&copyDocument)validateLoadingCopySource(editor.getSnapshot(),job,copyDocument);
// parser v5 使用文档流读取器,v4 兼容索引读取器;格式选择来自保存的协议,不靠扩展名猜测。
const importSource=source.kind==='duplicate-plan'?createDuplicatePlanSource(copyDocument??await decodeCopySnapshot(bytes,options.scope.projectId,options.registry,preparationController.signal),options.registry,source.plan!,job.sourceFingerprint,indexed?.readElement):source.kind==='native-document'?await (job.parserVersion===5?openStreamingDocumentImportSource:openDocumentImportSource)({bytes,readBytes,fingerprint:job.sourceFingerprint,signal:preparationController.signal}):source.kind==='native-json-array'?createJsonArrayImportSource({bytes,readBytes,fingerprint:job.sourceFingerprint}):createNdjsonImportSource({bytes,readBytes,fingerprint:job.sourceFingerprint});
if(closing||closed||preparationController.signal.aborted||getImport(id).generation!==job.generation)return;
if(importHolds.size){deferredImportLoads.add(id);return;}
// beforeCommit 与同步 commit 之间的临时资源授权;不是第二份持久资源表。
const authorizedImportAssets=new Map<Id,{ref:SharedAsset;bytes:Blob}>(),assetPreparationSignals=new WeakSet<AbortSignal>();
// Ordinary-batch ceiling only; commit timing and indivisible SCC limits still apply.
// 乐观本地模式允许边解析边落图;普通批次最多128项,积压32根或存储失败时施加保存背压。
// SCC 等不可分依赖单元另有限制;128 并不承诺每128项一定让出或一定耗时很短。
const runner=createImportRunner({maxBatch:128,optimisticLocal:true,checkpointSource:source.integrityVersion===2?checkpointImportSource:undefined,awaitDurabilityBudget:stage=>pending.size>=32||stage!=='parsed'&&persistenceFailed?persist():undefined,source:importSource,getJob:()=>getImport(id),createId,
// prepare 只生成候选 unit/稳定 ID,实际文档修改由后面的同步事务执行。
publish:publishImport,prepare:(line,job,reserve)=>['duplicate-ndjson','duplicate-plan'].includes(job.source?.kind??'')?prepareDuplicateImport(line,job,reserve,options.registry):prepareNativeImport(line,job,reserve,editor.getSnapshot().document,options.registry,depot&&options.assets?.authority?options.assets.sourceURL:undefined),persist,onError:report,
/** 原生文档格式引用的共享资源先逐项授权、校验 URL、读取字节;批次累计不超过32MiB。
* 异步阶段不改业务根,失败清空准备材料;同步 commit 仍须重新核对 job/目标/依赖。 */
async beforeCommit(units,job,signal){
// 其它源格式不走该外部资源领用路径;其引用规则由各自 unit 准备/守卫处理。
authorizedImportAssets.clear();if(job.source?.kind!=='native-document')return{enabled:true};
const ids=referencedResources(units.flatMap(unit=>unit.changes),options.registry);if(!ids.size)return{enabled:true};if(editor.getSnapshot().runtime.loading&&editor.getSnapshot().runtime.loading?.version!==6)return{enabled:false,code:'model_loading',targets:[]};
if(!depot||!options.assets?.authority||!options.storage.assetDepot)return{enabled:false,code:'asset_depot_unavailable',targets:[]};
if(!assetPreparationSignals.has(signal)){assetPreparationSignals.add(signal);signal.addEventListener('abort',()=>authorizedImportAssets.clear(),{once:true});}let total=0;
try{for(const id of ids){const authorized=await depot.authorize(leader,{scope:options.assets.authority.scope,resolve:(id,abort)=>network.run(abort,()=>options.assets!.authority!.resolve(id,abort)),read:(id,abort)=>network.run(abort,()=>options.assets!.authority!.read(id,abort))},id,signal);signal.throwIfAborted();assertImportWritable();if(authorized.ref.asset.url!==options.assets.sourceURL(id))throw fault('asset_authorization_mismatch');total+=authorized.bytes.size;if(total>32*1024*1024)throw fault('import_asset_capacity');authorizedImportAssets.set(id,authorized);}return{enabled:true};}catch(error){authorizedImportAssets.clear();return{enabled:false,code:(error as {code?:string}).code??'import_asset_authorization_failed',targets:[]};}
},
/** 将一批元素/canvas、授权资源绑定及 next job 检查点放入一次 editor 发布。
* 失败回滚的只是本批临时领用材料;此前成功加入并显示的批次保持不变。 */
commit(units,next,expected){
// 消费一次 beforeCommit 材料,防止后续批次或重入误用上次授权。
const preparedAssets=new Map(authorizedImportAssets);authorizedImportAssets.clear();
assertImportWritable();if(importing)throw fault('reentrant_dispatch');const current=getImport(id);if(current.generation!==expected)throw fault('stale_import_job');
// 等待资源授权期间目标或外部连线端点可能变化,提交前重新检查入口代次和依赖。
try{assertNativeImportTarget(current,editor.getSnapshot().document);for(const unit of units){assertNativeImportDependencies(current,editor.getSnapshot().document,unit.dependencies);if(['duplicate-ndjson','duplicate-plan'].includes(current.source?.kind??''))assertDuplicateExternal({cursor:0,nextCursor:1,key:unit.key,value:unit.guard},current,editor.getSnapshot().document,options.registry);}}catch(error){return reject(error);}
// 临时登记待保存的领用字节,同时保存旧值,保证事务拒绝后可准确恢复原暂存集合。
const previousFiles=new Map<Id,{readonly bytes:Blob;readonly rootRevision:number}|undefined>();let accepted=false;
try{for(const [assetId,authorized]of preparedAssets){const runtime=editor.getSnapshot().runtime,own=runtime.resources.get(assetId);if(own){if(!own.stored||own.digest!==authorized.ref.digest||!equal(own.stored,authorized.ref.asset))throw fault('shared_asset_identity_conflict',[assetId]);continue;}if(runtime.orphanFiles?.has(assetId)||importFiles(runtime.imports).has(assetId)||fileWrites.has(assetId)||collectionReservations.has(assetId))throw fault('resource_identity_conflict',[assetId]);previousFiles.set(assetId,adoptedFiles.get(assetId));adoptedFiles.set(assetId,{bytes:authorized.bytes,rootRevision:Number.MAX_SAFE_INTEGER});}importAdoptions=[...preparedAssets].filter(([assetId])=>!editor.getSnapshot().runtime.resources.has(assetId)).map(([,value])=>value.ref);}catch(error){for(const [assetId,previous]of previousFiles)if(previous)adoptedFiles.set(assetId,previous);else adoptedFiles.delete(assetId);authorizedImportAssets.clear();return reject(error);}
// reducer 在此同步窗口读 importing/importAdoptions,使 job 游标和新增元素同根更新。
importing={job:next,expectedGeneration:expected};
// 只允许创建元素/画布,所有 unit 共用逻辑导入组;成功后将领用字节绑定到实际发布 revision。
try{const result=editor.transaction(tx=>{for(const unit of units)for(const change of unit.changes){if(change.kind==='element'&&change.before===null&&change.after)tx.dispatch({type:'element.create',element:change.after});else if(change.kind==='canvas'&&change.before===null&&change.after)tx.dispatch({type:'canvas.create',canvas:change.after});else throw fault('invalid_native_import_unit');}},{label:['duplicate-ndjson','duplicate-plan'].includes(current.source?.kind??'')?'Duplicate selection':'Import canvas content',groupId:`import:${id}`});accepted=result.status==='applied';if(accepted)for(const assetId of previousFiles.keys())adoptedFiles.set(assetId,{bytes:preparedAssets.get(assetId)!.bytes,rootRevision:editor.getSnapshot().rootRevision});return result;}finally{importing=undefined;importAdoptions=[];if(!accepted)for(const [assetId,previous]of previousFiles)if(previous)adoptedFiles.set(assetId,previous);else adoptedFiles.delete(assetId);authorizedImportAssets.clear();}
},
});
// 装配完成后继承仍有效的全局 holds;业务 status=running 才启动加入画布的工作。
importRunners.set(id,runner);for(const releases of importHolds.values())releases.set(id,runner.hold());
if(getImport(id).status==='running')void runner.start();
// 中止不改业务终态;真实读取损坏留下完整性错误,其它装配失败转 paused。
// finally 清除执行索引,仅在仍有延迟恢复义务且 job 可运行时重新装配,避免覆盖后来换源的状态。
})().catch(error=>{if(preparationController.signal.aborted)return;if(!closing&&!closed){try{const job=getImport(id);if(job.source?.integrityVersion===2&&!importRunners.has(id)&&!verificationTasks.has(id)){proofReaders.get(id)?.reader.close();proofReaders.delete(id);}if(job.sourceFingerprint!==expectedFingerprint)return;if(job.source?.integrityVersion===2&&(isImportByteFailure(error)||(error as {code?:string}).code==='import_source_unavailable')){failImportVerification(job,error);void persist().catch(report);return;}if(['completed','partial','cancelled','paused'].includes(job.status))return;publishImport(importCheckpoint({...job,generation:job.generation+1,status:'paused',error:(error as {code?:string}).code??'import_failed'}),job.generation);}catch{/* Recovery/identity fences retain the original job. */}report(error);}}).finally(()=>{preparationControllers.delete(id);importLoads.delete(id);if(!['running','preparing'].includes(getImport(id).status))deferredImportLoads.delete(id);if(!closing&&!closed&&!importHolds.size&&deferredImportLoads.delete(id)&&!importRunners.has(id)&&['running','preparing'].includes(getImport(id).status))queueMicrotask(()=>{if(!closing&&!closed)void loadImport(id);});});
importLoads.set(id,task);return task;
}
/** 接收本地 Blob 并同步登记一个可恢复作业(源文件上限64MiB),随后后台读取解析。
* 文件 ID、源证明和目标入口代次从一开始就写进根,页面可立即观察 running,不等待全文件解析。 */
function startNativeImport(bytes:Blob,targetCanvasId:Id,format:'ndjson'|'json-array'|'document'):Id{
if(!['ndjson','json-array','document'].includes(format))throw fault('import_interpreter_unavailable');
assertImportWritable();const loadingAdmission=loadingImportAdmission(editor.getSnapshot(),targetCanvasId);const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||current.orphanFiles?.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict');
// 子分区以入口元素 ID+代次绑定目标;同名/同 ID 重建入口不能悄悄接收旧导入。
const entry=canvas.parent?editor.getSnapshot().document.elements.get(canvas.parent.entryElementId)!:undefined;
// 新导入采用分块完整性 v2;解析器版本按内容格式分别选择,不与 job/schema 版本混用。
const job=importCheckpoint({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:format==='document'?5:format==='json-array'?3:1,source:{...(loadingAdmission?{loadingAdmission}:{}),integrityVersion:2,readProof:{version:1,fileId,byteLength:bytes.size,blockBytes:IMPORT_PROOF_BLOCK_BYTES,blocks:[]},kind:format==='document'?'native-document':format==='json-array'?'native-json-array':'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const});
fileWrites.set(fileId,bytes);
integrityOwner=id;try{const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;}finally{integrityOwner=undefined;}
}
/** 项目公开的导入控制面:查询/能力判断无副作用,start/duplicate 登记作业,pause/resume/cancel 控制后续批次。
* 暂停、取消保留已发布前缀,原字节与完整性证据按各自生命周期保存和清理。 */
const imports=Object.freeze({
/** 返回根中当前作业的只读 Map,不暴露 runner 内部状态。 */
getJobs:()=>readMap(editor.getSnapshot().runtime.imports??new Map()),
/** 检查目标存在、策略与加载范围;只给出当前可用性,真正开始时仍重新校验。 */
canStart(targetCanvasId:Id){try{assertImportWritable();const snapshot=editor.getSnapshot(),permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);if(!snapshot.document.canvases.has(targetCanvasId))throw fault('canvas_not_loaded',[targetCanvasId]);loadingImportAdmission(snapshot,targetCanvasId);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}},
/** 检查选择代次、目标范围和清理占用;完整复制闭包/外部引用验证留给后续规划和提交。 */
canDuplicate(selection:readonly Target[],settings:{viewCanvasId?:Id}={}){try{assertImportWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]);const snapshot=editor.getSnapshot(),permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);if(!selection.length)throw fault('empty_copy_selection');for(const selected of selection){const element=snapshot.document.elements.get(selected.id);if(!element||element.incarnation!==selected.incarnation)throw fault('stale_incarnation',[selected.id]);}const target=settings.viewCanvasId??snapshot.document.elements.get(selection[0].id)!.ownerCanvasId;if(!snapshot.document.canvases.has(target))throw fault('canvas_not_loaded',[target]);loadingImportAdmission(snapshot,target,selection);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}},
// 原文件借阅返回带 release 的句柄;三种原生入口各自固定解析协议。
openSource:openImportSource,
startNdjson:(bytes:Blob,targetCanvasId:Id)=>startNativeImport(bytes,targetCanvasId,'ndjson'),
startDocument:(bytes:Blob,targetCanvasId:Id)=>startNativeImport(bytes,targetCanvasId,'document'),
startJsonArray:(bytes:Blob,targetCanvasId:Id)=>startNativeImport(bytes,targetCanvasId,'json-array'),
/** 捕获当前不可变文档和独立选择/偏移,异步编码索引快照,再登记 preparing 复制任务。
* 后续原画布编辑不会改变复制来源;完成编码不代表已创建全部副本。 */
async duplicate(selection:readonly Target[],settings:{readonly worldOffset?:Point;readonly signal?:AbortSignal;readonly viewCanvasId?:Id}={}):Promise<Id>{
assertImportWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]);
const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
// 快照编码会跨 await,先撤销未实际清除的退休标记,配合 copyPreparations pin 防止来源字节中途清理。
if([...editor.getSnapshot().runtime.resources.values(),...(editor.getSnapshot().runtime.orphanFiles?.values()??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined)){
const reset=editor.updateRuntime(previous=>{const resources=new Map(previous.resources);for(const [id,resource]of resources)if(resource.retirement&&resource.retirement.purgedAt===undefined){const {retirement:_,...kept}=resource;resources.set(id,freeze(kept));}const files=new Map(previous.orphanFiles);for(const [id,file]of files)if(file.retirement&&file.retirement.purgedAt===undefined){const {retirement:_,...kept}=file;files.set(id,freeze(kept));}return Object.freeze({...previous,resources:readMap(resources),...(previous.orphanFiles?{orphanFiles:readMap(files)}:{})});});if(reset.status==='rejected')throw fault(reset.code,reset.targets);
}
// 来源固定在 captured;复制目标入口也取自该快照,后续物化/提交再检验它是否仍有效。
const captured=editor.getSnapshot(),target=settings.viewCanvasId??(selection[0]?captured.document.elements.get(selection[0].id)?.ownerCanvasId:undefined),signal=settings.signal,controller=new AbortController(),abort=()=>controller.abort();
const loadingAdmission=target?loadingImportAdmission(captured,target,selection):undefined;
// 与调用方数组/对象断开引用;不能让外部在编码期间改选择或偏移,影响已捕获的复制意图。
const targets=detachJsonData(selection),offset=detachJsonData(settings.worldOffset??{x:32,y:32});
for(const selected of targets){const element=captured.document.elements.get(selected.id);if(!element||element.incarnation!==selected.incarnation)throw fault('stale_incarnation',[selected.id]);}if(![offset.x,offset.y].every(Number.isFinite))throw fault('invalid_copy_offset');
signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)controller.abort();
const task=(async()=>{
if(!target||!targets.length)throw fault('empty_copy_selection');
// 快照编码合作让出线程且支持取消;回到这里再检查项目写入资格。
const bytes=await encodeIndexedCopySnapshot(captured.document,controller.signal);assertImportWritable();if(controller.signal.aborted)throw fault('copy_aborted');
const canvas=captured.document.canvases.get(target);if(!canvas)throw fault('canvas_not_loaded',[target]);
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;
if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||current.orphanFiles?.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict');
const entry=canvas.parent?captured.document.elements.get(canvas.parent.entryElementId)!:undefined;
// 复制快照 v2 + 规划 v4 是当前组合;初始没有副本根 IDs,规划器会稳定分配并保存检查点。
const job=importCheckpoint({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{...(loadingAdmission?{loadingAdmission}:{}),kind:'duplicate-snapshot',snapshotVersion:2,name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:4 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}});
fileWrites.set(fileId,bytes);const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;
})();
// 从编码开始到作业登记结束持有准备任务/pin;所有结束路径清除任务及外部 abort 监听。
copyPreparations.set(controller,task);void task.finally(()=>{copyPreparations.delete(controller);signal?.removeEventListener('abort',abort);}).catch(()=>{});return task;
},
/** 有 runner 就停在其批次边界;未装配时直接推进 job generation 并保存 paused。
* 分块来源的验证仍可能继续补齐已提交前缀的证据,不能把暂停当作删除来源。 */
async pause(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.pause();else{const job=getImport(id);if(['running','preparing'].includes(job.status)){if(job.source?.integrityVersion!==2)preparationControllers.get(id)?.abort();publishImport(importCheckpoint({...job,generation:job.generation+1,status:'paused'}),job.generation);await persist();}}},
/** 终态不重启;paused 先等旧装配/runner 结束,必要时重建证明读取器,再保存并恢复调度。
* 复制快照回 preparing,其它源回 running,避免跳过尚未完成的复制规划。 */
async resume(id:Id){assertImportWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(['running','preparing'].includes(job.status)){await loadImport(id);return;}await importLoads.get(id);await importRunners.get(id)?.settle();if(getImport(id).source?.integrityVersion===2)await resetImportReader(id);await persist();await scheduler.resume();assertImportWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:current.source?.kind==='duplicate-snapshot'?'preparing' as const:'running' as const};delete next.error;publishImport(importCheckpoint(next),current.generation);await loadImport(id);},
/** 取消后续加入,保留已有内容;尚欠 seal 时仍可继续校验,证据完整才中止额外验证。 */
async cancel(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','preparing','paused'].includes(job.status)){if(job.source?.integrityVersion!==2)preparationControllers.get(id)?.abort();const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(importCheckpoint(next),job.generation);await persist();}}if(!sourceNeedsSeal(getImport(id)))verificationTasks.get(id)?.controller.abort();},
});
// 全部协调回调已接线后启动调度,恢复既有生成轮询和资源任务。
scheduler.start(); prepareDigests();prepareImages(); runTracker.notify([...initial.runs.keys()]);
// 终态作业若仍欠 seal 也装配验证;running/preparing 则恢复尚未完成的内容处理。
for(const job of initial.imports?.values()??[])if(sourceNeedsSeal(job)||['running','preparing'].includes(job.status))void loadImport(job.id);
return Object.freeze({
editor, sessions, imports,