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

65 lines
5.4 KiB
TypeScript

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};
export type PreviousImportFile={readonly fileId:Id;readonly byteLength:number;readonly sha256?:string;readonly retirement?:FileRetirement};
export type ImportFile={readonly jobId:Id;readonly current:boolean;readonly file:PreviousImportFile};
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);
}
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;
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;
}
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;
}
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. */
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;
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);
}