/** * 历史残留文件的盘点、保留与身份保护。孤儿不是“当前画布没显示”,而是经过完整引用/任务检查 * 仍无归属的实际文件;注册后保留身份,永久清理后也不能忘记记录或把 ID 重新分给其它内容。 */ import type {Id,Registry} from '../canvas-core/types.ts'; import {equal,fault,freeze,readMap} from '../canvas-core/values.ts'; import type {EngineSnapshot,RuntimeState} from './contracts.ts'; import {importFiles,terminalImport,validFileRetirement,type FileRetirement} from './import-file-retention.ts'; import {resourceRetentionPins} from './resource-retention.ts'; /** 盘点确认的文件身份/摘要与退休状态,不是上传资源元数据,也不是 DOM 图片缓存。 */ export type OrphanFile={readonly version:1;readonly id:Id;readonly byteLength:number;readonly mimeType:string;readonly digest:string;readonly retirement?:FileRetirement}; /** 检查记录版本、键与 ID 相同、安全字节数、MIME 字符串、64 位小写十六进制摘要和退休标记。 * 不读取 Blob 或重算 SHA;MIME 只要求字符串,不在此证明真实内容类型或元数据来自可信盘点。 */ export function validateOrphanFile(value:unknown,id:Id):asserts value is OrphanFile { const file=value as OrphanFile; if(!file||file.version!==1||file.id!==id||!id||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest)||file.retirement!==undefined&&!validFileRetirement(file.retirement))throw fault('invalid_orphan_file'); } /** 在 resourceRetentionPins 上叠加孤立文件专用阻碍,返回已知 pins 与 blockers。 * 任意 run/recovery、非终态导入、copy-admission 或未解决的其它动作都阻止孤儿回收。 * complete 只表示本函数列出的引用/生产者条件通过;不代表盘点到末页、文件散列完成或删除已持久化。 */ export function orphanCoverage(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap,temporary:ReadonlyMap) { const pins=resourceRetentionPins(snapshot,registry,displays,temporary),blockers:string[]=[]; if(!pins.complete)blockers.push('opaque_references'); if(snapshot.runtime.runs.size)blockers.push('opaque_run_records'); if(snapshot.runtime.recoveries.size)blockers.push('recovery_audits'); if([...(snapshot.runtime.imports?.values()??[])].some(job=>!terminalImport(job.status)))blockers.push('unfinished_imports'); if(temporary.has('copy-admission'))blockers.push('copy_admission'); if([...snapshot.runtime.ledger.operations.values()].some(record=>record.phase!=='resolved'&&!['document.change','asset.upload'].includes(record.intent.operationType)))blockers.push('opaque_pending_actions'); return Object.freeze({...pins,complete:blockers.length===0,blockers:Object.freeze(blockers)}); } /** Registered orphan identities survive repinning. Purged identities cannot be forgotten or reassigned. */ /** 在发布前维护孤儿记录:禁止与资源/源文件重名,旧记录不得消失,已 purge 记录必须完整相等。 * 未 purge 记录允许盘点方更新摘要/长度等事实,本函数不读取字节来证明这种更新。 * referencesChanged 和 Map 引用用于快路判断;调用方须准确报告文档/历史/账本等引用变化。 * reservation 仅检查 next.orphanFiles 中对应 ID;跨文件类别的统一阻拦由外围发布钩子负责。 */ export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,coverage:()=>{complete:boolean;pins:ReadonlyMap},reserved:ReadonlySet,referencesChanged:boolean):RuntimeState { if(!previous.orphanFiles?.size&&!next.orphanFiles?.size)return next; const hasSoft=[...(next.orphanFiles?.values()??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined); if(previous.orphanFiles===next.orphanFiles&&previous.resources===next.resources&&previous.imports===next.imports&&!reserved.size&&!referencesChanged&&!hasSoft)return next; // 即使孤儿后来被引用也保留其注册身份;只能撤销软退休,不能从表里悄悄删掉历史事实。 const sources=importFiles(next.imports); for(const [id,file]of next.orphanFiles??[]){validateOrphanFile(file,id);if(next.resources.has(id)||sources.has(id))throw fault('local_file_identity_conflict',[id]);} for(const [id,file]of previous.orphanFiles??[]){const after=next.orphanFiles?.get(id);if(!after||file.retirement?.purgedAt!==undefined&&!equal(file,after))throw fault('local_file_retired',[id]);} // 覆盖不足/重 pin 取消软退休;永久删除后重 pin 明确拒绝,不能让模型引用失去字节的 ID。 // 需要慢路才调用覆盖回调;即使引用覆盖未知,已 purge 项也仅在有明确 pin 时拒绝。 const pins=coverage(),clear:Id[]=[]; for(const [id,file]of next.orphanFiles??[]){ if(reserved.has(id)&&(!pins.complete||pins.pins.has(id)))throw fault('resource_collection_in_progress',[id]); if(file.retirement?.purgedAt!==undefined){if(pins.pins.has(id))throw fault('local_file_retired',[id]);} else if(file.retirement&&(!pins.complete||pins.pins.has(id)))clear.push(id); } if(!clear.length)return next; const files=new Map(next.orphanFiles);for(const id of clear){const {retirement:_,...file}=files.get(id)!;files.set(id,freeze(file));}return Object.freeze({...next,orphanFiles:readMap(files)}); } /** Read-only I/O may finish late; cancellation detaches its result without publishing it. */ /** 包装已启动的 Promise:取消以 file_inventory_aborted 拒绝,不沿用 signal.reason。 * 不终止底层只读 I/O;仍安装成功/失败处理器接住迟到结果,并在完成/取消时移除监听。 * 不持有 Root,也不校验结果是否过期;盘点调用方还需在 await 后核对当前快照身份。 */ export function abortableFileRead(task:Promise,signal:AbortSignal):Promise{ return new Promise((resolve,reject)=>{const abort=()=>{signal.removeEventListener('abort',abort);reject(fault('file_inventory_aborted'));};signal.addEventListener('abort',abort,{once:true});if(signal.aborted)abort();task.then(value=>{signal.removeEventListener('abort',abort);if(!signal.aborted)resolve(value);},error=>{signal.removeEventListener('abort',abort);if(!signal.aborted)reject(error);});}); }