From 437a46ad365a4e8f9913f7856ed12115c93ee27f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 13:33:34 +0800 Subject: [PATCH] docs(runtime): explain progressive previews and loading asset evidence --- src/lib/canvas-runtime/loading-assets.ts | 21 ++++++++++++++++----- src/lib/canvas-runtime/model-preview.ts | 23 +++++++++++++++++------ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/lib/canvas-runtime/loading-assets.ts b/src/lib/canvas-runtime/loading-assets.ts index 92c7b36..481f3e7 100644 --- a/src/lib/canvas-runtime/loading-assets.ts +++ b/src/lib/canvas-runtime/loading-assets.ts @@ -11,7 +11,10 @@ import {referencedResources,type LoadingAssetAdmission} from './resources.ts'; /** Proven local upload identities; never infer authorization from an arbitrary URL. */ // source身份+各proof身份一致时复用资源URL表;不因浏览器缓存有URL就授予准入。 const namespaces=new WeakMap;urls:ReadonlyMap}>(); -/** 从已记录loadingAdmission派生resource→规范来源URL,只在v6和保留源存在时提供。 */ +/** v6 且 loadingRead 存在时提取 resource/binding 的 admission.sourceURL,不执行认证或完整 proof 校验。 + * 以源对象与各 proof 引用身份缓存;依赖 runtime/proof 不可变合同,准入校验应由外围先完成。 + * 返回实际可变 Map(类型只读),命中时沿用同一表,内部调用方不得修改;否则会污染后续复用。 + * 同 ID 若两表均有 proof 后者覆盖,资源命名空间无冲突由其它运行时校验保证。 */ export function loadingAssetURLs(runtime:RuntimeState):ReadonlyMap{ const source=runtime.loadingRead;if(!source||runtime.loading?.version!==6)return new Map(); const proofs=new Map();for(const resource of runtime.resources.values())if(resource.loadingAdmission)proofs.set(resource.id,resource.loadingAdmission); @@ -19,10 +22,14 @@ export function loadingAssetURLs(runtime:RuntimeState):ReadonlyMap{ const previous=namespaces.get(source);if(previous&&previous.proofs.size===proofs.size&&[...proofs].every(([id,proof])=>previous.proofs.get(id)===proof))return previous.urls; const urls=new Map([...proofs].map(([id,proof])=>[id,proof.sourceURL]));namespaces.set(source,{proofs,urls});return urls; } -/** 联合验证上传/采纳与源范围、操作顺序、引用和stored证明;最后核对冻结上传协议字段。 */ +/** 仅 v6 校验全部本地 resource/binding 的准入:token/范围、逻辑因果与冻结上传 body 一致性。 + * 要求外围已验证 loadingRead/ledger/资源结构;这里不下载字节、重算摘要、请求授权或核验真实服务回执。 + * referencedResources 是声明字段的保守引用集(含变化前后),顺序检查不单独证明某条记录当前仍可显示。 + * sourceURL 对 upload 只查非空/长度,对 adoption 要与 binding.asset.url 相等,规范来源由受信端口提供。 */ export function validateLoadingAssetAdmissions(runtime:RuntimeState,registry:Registry):void{ const loading=runtime.loading;if(loading?.version!==6)return; - // upload先分配资源,后续document.change才能引用;早于/同于upload的引用不具备可靠身份因果。 + // 每个 upload resource 都要有对应上传意图;stored 存在时 resolution 必须 applied。 + // 至少有一个后续 document.change 引用且不允许早于/等于上传序号的引用;不按 ledger phase 过滤。 for(const resource of runtime.resources.values()){ const proof=resource.loadingAdmission,record=runtime.ledger.operations.get(resource.operationId); if(!proof||proof.version!==1||proof.sourceToken!==loading.sourceToken||typeof proof.sourceURL!=='string'||!proof.sourceURL||proof.sourceURL.length>8192||!Array.isArray(proof.canvasIds)||!proof.canvasIds.length||new Set(proof.canvasIds).size!==proof.canvasIds.length||proof.canvasIds.some(id=>typeof id!=='string'||!structuralRequestScope(runtime,runtime.loadingRead!,id))||record?.intent.operationType!=='asset.upload'||record.intent.resourceId!==resource.id||resource.stored&&record.resolution!=='applied')throw fault('invalid_loading_asset',[resource.id]); @@ -35,7 +42,8 @@ export function validateLoadingAssetAdmissions(runtime:RuntimeState,registry:Reg if(!proof||proof.version!==1||proof.operationId!==binding.operationId||proof.sourceToken!==loading.sourceToken||proof.sourceURL!==binding.asset.url||!Array.isArray(proof.canvasIds)||!proof.canvasIds.length||new Set(proof.canvasIds).size!==proof.canvasIds.length||proof.canvasIds.some(owner=>!structuralRequestScope(runtime,runtime.loadingRead!,owner))||record?.intent.operationType!=='document.change'||!referencedResources(record.intent.changes,registry).has(id))throw fault('invalid_loading_asset',[id]); if([...runtime.ledger.operations.values()].some(candidate=>candidate.intent.operationType==='document.change'&&candidate.intent.sequence>>(); -/** 资源首次出现在逻辑回放中时,实际owner集合须精确匹配准入范围;seen只在本次验证中去重。 */ +/** 外围应先将本 intent 应用到逻辑候选 document,再在首次遇有 admission 的资源时比较实际 owner 集合。 + * registry+document 身份缓存全表资源→owner 索引,复用依赖不可变文档/定义;统计内容引用,不使用 DOM 可见性。 + * 无 proof 或已 seen 的资源跳过,后续意图不重复做首次范围比较;一般资源合法性由其它门禁负责。 + * seen 在比较前更新,失败后应丢弃本轮 Set,不能将失败结果当作已验证缓存继续使用。 */ export function validateLoadingAssetUse(intent:OperationIntent,document:Document,runtime:RuntimeState,registry:Registry,seen:Set):void{ if(runtime.loading?.version!==6)return; for(const id of referencedResources(intent.changes,registry)){ diff --git a/src/lib/canvas-runtime/model-preview.ts b/src/lib/canvas-runtime/model-preview.ts index 5592740..067d3a6 100644 --- a/src/lib/canvas-runtime/model-preview.ts +++ b/src/lib/canvas-runtime/model-preview.ts @@ -16,7 +16,10 @@ export type LiveModelPreview=ModelPreview&{readonly loading:import('./loading.ts export type ModelPreview = { readonly canvasElementCounts?:CanvasElementCounts;readonly elementOwners?:ElementOwners;readonly sourceCanvases?:readonly CanvasRecord[]; readonly document:Document;readonly receivedElements:number;readonly totalElements:number;readonly waitingElements:number;readonly complete:false }; /** An ephemeral read projection. It owns neither a working document nor ACKs, * history, operations, file bytes or a durable recovery cut. */ -/** 建立单捕获投影器;一旦失败/终结就关闭,避免后续块继续污染合法旧预览。 */ +/** 一个流捕获的临时解释器:canvases 保存全源树,records 保存已收未发布元素,known 保存已发布命名空间键。 + * published/snapshot 是最后合法前缀,received/ownerCounts 统计接收量,二者不等同可见/可编辑范围。 + * 更新为同步建图/校验,无独立预算/取消或等待表容量上限;源规模与调用节奏由 loader/宿主约束。 + * 任一更新失败后禁止继续,公开旧 snapshot 不变,但不保证所有内部计数/临时表事务回滚。 */ export function createModelPreview(registry:Registry){ let elementOwners:ElementOwners|undefined;let canvasElementCounts:CanvasElementCounts|undefined;const ownerCounts=new Map(); let header=false,finished=false,projectId='',expected=0,received=0; @@ -26,10 +29,14 @@ export function createModelPreview(registry:Registry){ const key=(kind:'canvas'|'element',id:Id)=>JSON.stringify([kind,id]); // known 只存已经进入合法 published 前缀的键;records 暂存已收到但还未闭合的元素。 const known=new Set(); - /** 接收 header/增量/terminal;每批先校验所有记录,再计算新的最大闭合候选集合。 */ + /** 接收单次 canonical update,先校验批,再按依赖排除求最大可发布候选并验证整份前缀。 + * 不比较 sourceToken/serverVersions 或网络 scope;这些捕获身份由外层 LoadChunk/loader 管理。 + * 只有 receiving 更新会生成 ModelPreview;终态仅封闭解释器并返回旧 snapshot,complete 仍为 false。 */ function update(value:CanonicalReadUpdate):ModelPreview|undefined{ if(finished)throw fault('model_preview_closed'); - // terminal 必须含相同项目/树/墓碑和全部已收到记录,不能用另一份 fresh read 假装补完当前源。 + // 已有 header 时,terminal 必须 ready 且项目/全树/墓碑/接收总数与所有已收记录精确相符。 + // 这里不再次 validateDocument(terminal) 或要求 waiting=0;合法完整终态由 adapter/engine 另验。 + // 无 header 就先遇非 receiving 时直接结束并返回 undefined,不据此生成一个完整预览。 if(value.status!=='receiving'){ if(header&&(value.status!=='ready'||value.document.projectId!==projectId||received!==expected||value.document.canvases.size!==canvases.size||[...canvases].some(([id,canvas])=>!equal(value.document.canvases.get(id),canvas))||!equal(Object.fromEntries(value.document.tombstones),Object.fromEntries(tombstones))||value.document.elements.size!==expected||[...(published?.elements??[]),...records].some(([id,record])=>!equal(value.document.elements.get(id),record))))throw fault('model_preview_changed'); finished=true;return snapshot; @@ -56,6 +63,7 @@ export function createModelPreview(registry:Registry){ for(const record of value.elements){if(records.has(record.id)||known.has(key('element',record.id))||incoming.has(record.id)||!canvases.has(record.ownerCanvasId))throw fault('invalid_model_preview');validateElementRecord(record,registry,tombstones);if(elementOwners&&(!Object.hasOwn(elementOwners,record.id)||elementOwners[record.id]!==record.ownerCanvasId))throw fault('invalid_model_identity');incoming.add(record.id);} for(const record of value.elements){const count=(ownerCounts.get(record.ownerCanvasId)??0)+1;if(canvasElementCounts&&count>canvasElementCounts[record.ownerCanvasId])throw fault('invalid_model_coverage');ownerCounts.set(record.ownerCanvasId,count);} for(const record of value.elements)records.set(record.id,freeze(clone(record)));received=value.receivedElements; + // 已知组件只按 copy.references 加数据引用依赖,unknown 组件保持 opaque,不猜其私有 ID。 // Greatest closed subset: removing an unavailable dependency also removes its // dependants. Fully received cycles survive and publish in one legal prefix. // 候选图包含尚未发布的 canvas/记录,依赖为父/入口/owner/端点及组件明确声明引用。 @@ -68,17 +76,20 @@ export function createModelPreview(registry:Registry){ for(const ref of definition?.copy?.references??[])for(const id of copyReferenceIds(element.data,ref))deps.add(key(ref.target,id)); candidates.set(key('element',element.id),{element,deps}); } - // 从“缺少且未收到的依赖”向反向依赖传播排除;完全收到的环可保留并在同一合法前缀发布。 + // dependants 是依赖键→使用者集合;queue 数组+游标作 FIFO,将缺失依赖的影响向引用者传播。 + // 非拓扑逐项插入:完整收到的环不会因没有入度零节点被丢弃,仍需通过最终文档校验才可发布。 const dependants=new Map>(),blocked=new Set(),queue:string[]=[]; for(const [id,item]of candidates)for(const dependency of item.deps){let users=dependants.get(dependency);if(!users){users=new Set();dependants.set(dependency,users);}users.add(id);if(!known.has(dependency)&&!candidates.has(dependency)&&!blocked.has(id)){blocked.add(id);queue.push(id);}} for(let index=0;indexsnapshot,close(){finished=true;records.clear();canvases.clear();known.clear();published=undefined;snapshot=undefined;}}); }