Files
Endoleap/src/lib/canvas-runtime/import-file-retention.ts
T

94 lines
8.5 KiB
TypeScript

/**
* 导入原源与转换旧源文件的保留/回收事实。文件ID一旦归某job,不能从记录中消失、换成upload资源或复用内容。
* 软退休可因重pin/覆盖不完整撤销,purge后身份保留且不允许恢复执行;文件删除仍由存储原子批次完成。
*/
import type {Id} from '../canvas-core/types.ts';
import {equal,fault,freeze,readMap} from '../canvas-core/values.ts';
import type {ImportJob,NativeImportSource} from './imports.ts';
import type {RuntimeState} from './contracts.ts';
/** 版本化保留时钟:首次无引用时间与可选永久清理时间,后者不得早于前者。 */
export type FileRetirement={readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number};
/** 源转换后仍登记的旧文件元数据,保留其摘要/退休身份,而不是直接丢旧fileId。 */
export type PreviousImportFile={readonly fileId:Id;readonly byteLength:number;readonly sha256?:string;readonly retirement?:FileRetirement};
/** 由所有job派生的文件归属,current区分当前源与历史转换源,不另存可漂移归属表。 */
export type ImportFile={readonly jobId:Id;readonly current:boolean;readonly file:PreviousImportFile};
/** completed/partial/cancelled是本任务终态;paused/preparing不代表源已经不再需要。 */
export const terminalImport=(status:ImportJob['status'])=>['completed','partial','cancelled'].includes(status);
/** 检查版本及非负安全整数时间,purgedAt 不得早于 unreferencedAt。
* 不与当前时钟比较,也不证明保留期已满、引用已清空或删除已提交;这些由收集器/存储边界保证。 */
export function validFileRetirement(value:unknown):value is FileRetirement {
const marker=value as FileRetirement;
return !!marker&&marker.version===1&&Number.isSafeInteger(marker.unreferencedAt)&&marker.unreferencedAt>=0&&(marker.purgedAt===undefined||Number.isSafeInteger(marker.purgedAt)&&marker.purgedAt>=marker.unreferencedAt);
}
/** 从有 source 的 job 汇总当前/previous 文件,检查 job 键与 ID 及全表 fileId 唯一性。
* 重复包括同 job 的 current/previous 重复;无 source 的 job 不贡献文件。
* 只派生索引,不完整校验文件元数据;返回实际可变 Map(类型只读),值中的 file 沿用输入引用。 */
export function importFiles(jobs:ReadonlyMap<Id,ImportJob>|undefined):ReadonlyMap<Id,ImportFile> {
const files=new Map<Id,ImportFile>();
for(const [jobId,job]of jobs??[]){
if(jobId!==job.id)throw fault('import_file_identity_conflict',[jobId]);
if(!job.source)continue;
/** 同一个fileId只能出现一次,不能靠current/previous分类掩盖冲突归属。 */
const add=(file:PreviousImportFile,current:boolean)=>{if(files.has(file.fileId))throw fault('import_file_identity_conflict',[file.fileId]);files.set(file.fileId,{jobId:job.id,current,file});};
add(job.source,true);for(const file of job.source.previousFiles??[])add(file,false);
}
return files;
}
/** 当前源带任何退休标记时都要求终态;previous 文件可独立退休,逐项检查 ID/长度/可选摘要/标记。
* 最后检查 current+previous ID 互异;当前源自身的 fileId/长度/摘要等仍由导入源完整校验负责。 */
export function validateImportFileRetention(source:NativeImportSource,status:ImportJob['status']) {
if(source.retirement!==undefined&&(!validFileRetirement(source.retirement)||!terminalImport(status)))throw fault('invalid_import_file_retirement');
if(source.previousFiles!==undefined&&(!Array.isArray(source.previousFiles)||source.previousFiles.some(file=>!file||typeof file.fileId!=='string'||!file.fileId||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||file.sha256!==undefined&&!/^[a-f0-9]{64}$/.test(file.sha256)||file.retirement!==undefined&&!validFileRetirement(file.retirement))))throw fault('invalid_import_file_retirement');
const ids=[source.fileId,...(source.previousFiles??[]).map(file=>file.fileId)];if(new Set(ids).size!==ids.length)throw fault('import_file_identity_conflict');
}
/** 仅替换/移除 retirement;相等时沿用原对象,否则返回冻结记录。
* 不验证新标记或任务终态,也不删除文件;调用方仍需经过发布/源结构校验。 */
function mark<T extends PreviousImportFile>(file:T,retirement:FileRetirement|undefined):T {
if(equal(file.retirement,retirement))return file;
const {retirement:_,...rest}=file;return freeze(retirement?{...rest,retirement}:rest) as T;
}
/** 按 fileId 更新 current/previous 标记,首次修改才复制 imports,每个变化 job 的 generation 只加一次。
* 未命中任何文件的 marker 忽略,重复同值无变化时复用 runtime;这里只改元数据,不校验退休资格或删字节。
* 新 generation 溢出则拒绝,不能生成无法可靠恢复的任务版本。 */
export function markImportFiles(runtime:RuntimeState,markers:ReadonlyMap<Id,FileRetirement|undefined>):RuntimeState {
let imports=runtime.imports;
for(const [id,job]of runtime.imports??[]){
if(!job.source)continue;
let source=markers.has(job.source.fileId)?mark(job.source,markers.get(job.source.fileId)):job.source;
const previous=source.previousFiles?.map(file=>markers.has(file.fileId)?mark(file,markers.get(file.fileId)):file);
if(previous&&previous.some((file,index)=>file!==source.previousFiles![index]))source=freeze({...source,previousFiles:previous});
if(source!==job.source){if(!Number.isSafeInteger(job.generation+1))throw fault('invalid_import_job');if(imports===runtime.imports)imports=new Map(imports);(imports as Map<Id,ImportJob>).set(id,freeze({...job,source,generation:job.generation+1}));}
}
return imports===runtime.imports?runtime:Object.freeze({...runtime,imports:readMap(imports!)});
}
/** A source file cannot disappear from its owning job, become an asset or regain execution after purge. */
/** 发布前校验文件归属/长度/已有摘要单调性,并清掉重 pin 或覆盖不完整的软退休。
* 已 purge 的 retirement 不得改写且不能再 pin;当前源若带标记回到非终态也会被结构校验拒绝。
* reserved 只让此函数绕过快路,不逐 ID 检查占用;统一 reservation 屏障由外围资源保留钩子执行。
* referencesChanged 默认 true;传 false 的调用方须确保引用变化已准确反映,不能仅凭 Map 相等猜完整性。 */
export function prepareImportFileRetention(previous:RuntimeState,next:RuntimeState,pins:()=>{complete:boolean;pins:ReadonlyMap<Id,{has(owner:string):boolean}>},reserved:ReadonlySet<Id>,referencesChanged=true):RuntimeState {
const hasSoft=[...(next.imports?.values()??[])].some(job=>job.source&&[job.source,...(job.source.previousFiles??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined));
if(previous.imports===next.imports&&previous.resources===next.resources&&previous.resourcePins===next.resourcePins&&!reserved.size&&!hasSoft&&!referencesChanged)return next;
// 旧源可由current降为previous,但不可反向复活;已有摘要/长度/所属job不能在转换时改变。
const before=importFiles(previous.imports),after=importFiles(next.imports);
for(const job of next.imports?.values()??[])if(job.source)validateImportFileRetention(job.source,job.status);
for(const id of after.keys())if(next.resources.has(id))throw fault('import_file_identity_conflict',[id]);
for(const [id,old]of before){
const entry=after.get(id);
if(!entry||entry.jobId!==old.jobId||entry.file.byteLength!==old.file.byteLength||old.file.sha256!==undefined&&entry.file.sha256!==old.file.sha256||!old.current&&entry.current)throw fault('import_file_identity_conflict',[id]);
if(old.file.retirement?.purgedAt!==undefined&&!equal(old.file.retirement,entry.file.retirement))throw fault('import_source_retired',[id]);
}
// 无软标记、reservation 或 purge 记录时不调用 pins;身份校验已完成,不等于执行了完整引用扫描。
if(!hasSoft&&!reserved.size&&![...after.values()].some(entry=>entry.file.retirement?.purgedAt!==undefined))return next;
// 覆盖未知或重新引用都清软标记,下一次无引用须重新计保留期,不沿用旧时钟直接删除。
// 新摘要可在旧值缺失时补齐,但已有摘要不能替换;清标记通过 markImportFiles 同步推进 job generation。
const coverage=pins(),clear=new Map<Id,undefined>();
for(const [id,entry]of after){
if(entry.file.retirement?.purgedAt!==undefined){if(coverage.pins.has(id))throw fault('import_source_retired',[id]);continue;}
if(!entry.file.retirement)continue;
if(!coverage.complete||coverage.pins.has(id))clear.set(id,undefined);
}
return markImportFiles(next,clear);
}