82 lines
7.1 KiB
TypeScript
82 lines
7.1 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);
|
|
/** 检查退休协议与安全时间值;只是结构合法,不证明引用扫描已完整。 */
|
|
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);
|
|
}
|
|
/** 汇总全部当前/旧源,验证job键与ID一致且同fileId不跨job重复,供原子回收检查使用。 */
|
|
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;
|
|
}
|
|
/** 当前源仅终态可退休;旧源可独立退休,且同job文件ID互异、元数据和摘要合法。 */
|
|
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');
|
|
}
|
|
/** 只改退休标记并保留文件身份;相同标记复用原对象,清标记不删除文件记录。 */
|
|
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批量更新当前/旧源标记,必要时copy-on-write并推进job.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. */
|
|
/** 每次Root发布前校验源身份单调性并取消不安全软退休;已purge仍被pin明确拒绝。 */
|
|
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]);
|
|
}
|
|
if(!hasSoft&&!reserved.size&&![...after.values()].some(entry=>entry.file.retirement?.purgedAt!==undefined))return next;
|
|
// 覆盖未知或重新引用都清软标记,下一次无引用须重新计保留期,不沿用旧时钟直接删除。
|
|
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);
|
|
}
|