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|undefined):ReadonlyMap { const files=new Map(); 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(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):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).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},reserved:ReadonlySet,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(); 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); }