64 lines
6.2 KiB
TypeScript
64 lines
6.2 KiB
TypeScript
/**
|
|
* 历史残留文件的盘点、保留与身份保护。孤儿不是“当前画布没显示”,而是经过完整引用/任务检查
|
|
* 仍无归属的实际文件;注册后保留身份,永久清理后也不能忘记记录或把 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<Id,number>,temporary:ReadonlyMap<Id,readonly Id[]>) {
|
|
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<Id,unknown>},reserved:ReadonlySet<Id>,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<T>(task:Promise<T>,signal:AbortSignal):Promise<T>{
|
|
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);});});
|
|
}
|