docs(runtime): explain progressive previews and loading asset evidence

This commit is contained in:
2026-11-12 13:33:34 +08:00
parent 2a9667a287
commit 437a46ad36
2 changed files with 33 additions and 11 deletions
+16 -5
View File
@@ -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<object,{proofs:Map<Id,LoadingAssetAdmission>;urls:ReadonlyMap<Id,string>}>();
/** 从已记录loadingAdmission派生resource→规范来源URL,只在v6和保留源存在时提供。 */
/** v6 且 loadingRead 存在时提取 resource/binding 的 admission.sourceURL,不执行认证或完整 proof 校验。
* 以源对象与各 proof 引用身份缓存;依赖 runtime/proof 不可变合同,准入校验应由外围先完成。
* 返回实际可变 Map(类型只读),命中时沿用同一表,内部调用方不得修改;否则会污染后续复用。
* 同 ID 若两表均有 proof 后者覆盖,资源命名空间无冲突由其它运行时校验保证。 */
export function loadingAssetURLs(runtime:RuntimeState):ReadonlyMap<Id,string>{
const source=runtime.loadingRead;if(!source||runtime.loading?.version!==6)return new Map();
const proofs=new Map<Id,LoadingAssetAdmission>();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<Id,string>{
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<record.intent.sequence&&referencedResources(candidate.intent.changes,registry).has(id)))throw fault('invalid_loading_asset',[id]);
}
// 冻结请求必须是原资源/operation/digest/长度/MIME,且不伪造canvas版本参与者。
// 每个 asset.upload 冻结请求必须只对应一个资源意图,协议 2 且 canvas/expectedVersions 为空。
// JSON body 与原 request/resource/operation/digest/长度/名称/MIME 精确匹配;不在此重新编译或修改冻结请求。
for(const request of runtime.ledger.requests.values())if(request.kind==='asset.upload'){
const intent=request.operationIds.length===1?runtime.ledger.operations.get(request.operationIds[0])?.intent:undefined,resource=intent?.operationType==='asset.upload'?runtime.resources.get(intent.resourceId):undefined;
if(!resource?.digest||request.protocolVersion!==2||request.canvasIds.length||Object.keys(request.expectedVersions).length)throw fault('invalid_loading_request');
@@ -46,7 +54,10 @@ export function validateLoadingAssetAdmissions(runtime:RuntimeState,registry:Reg
// 每registry+文档身份缓存资源的实际owner集合,完整比较首用范围时避免重复全表扫描。
const ownership=new WeakMap<Registry,WeakMap<Document,Map<Id,readonly Id[]>>>();
/** 资源首次出现在逻辑回放中时,实际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<Id>):void{
if(runtime.loading?.version!==6)return;
for(const id of referencedResources(intent.changes,registry)){
+17 -6
View File
@@ -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<Id,number>();
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<string>();
/** 接收 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<string,Set<string>>(),blocked=new Set<string>(),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;index<queue.length;index++)for(const id of dependants.get(queue[index])??[])if(!blocked.has(id)){blocked.add(id);queue.push(id);}
// 在已发布前缀上加全部未阻塞候选,整份文档校验后才更新 published/known。
// 先在旧 published 上加全部未阻塞候选,create/validate 成功后才更新 published/known 并移除 records。
// 无新增候选时复用原 document,但接收计数仍能生成新 snapshot;等待中不意味着已丢弃该记录。
const nextCanvases=[...(published?.canvases.values()??[])],nextElements=[...(published?.elements.values()??[])],accepted:string[]=[];
for(const [id,item]of candidates)if(!blocked.has(id)){if(item.canvas)nextCanvases.push(item.canvas);if(item.element)nextElements.push(item.element);accepted.push(id);}
if(!published||accepted.length){const next=createDocument(projectId,nextCanvases,nextElements,tombstones);validateDocument(next,registry);published=next;for(const id of accepted){known.add(id);const item=candidates.get(id);if(item?.element)records.delete(item.element.id);}}
// 预览计数始终来自同捕获;v5/v6使用的 sourceCanvases 保留完整源拓扑,不由当前可见树猜。
snapshot=Object.freeze({...(elementOwners?{elementOwners,sourceCanvases:freeze([...canvases.values()])}:{}),...(canvasElementCounts?{canvasElementCounts}:{}),document:published,receivedElements:received,totalElements:expected,waitingElements:received-published.elements.size,complete:false});return snapshot;
}
/** 对外 update 失败即封闭解释器;getSnapshot 只读旧合法前缀,close 清临时表而不删除业务文档。 */
/** update 失败即封闭且保留最后合法 snapshot 供诊断;getSnapshot 初始/显式 close 后为 undefined。
* close 清内部表和快照引用,不修改调用方已持有的不可变前缀,也不删除/撤销业务 engine 内容。 */
return Object.freeze({update(value:CanonicalReadUpdate){try{return update(value);}catch(error){finished=true;throw error;}},getSnapshot:()=>snapshot,close(){finished=true;records.clear();canvases.clear();known.clear();published=undefined;snapshot=undefined;}});
}