Retire terminal import sources with durable ownership and recovery v4
This commit is contained in:
@@ -26,6 +26,7 @@ export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; c
|
||||
{jobs.map((job, index) => <section className={styles.importJob} key={job.id} aria-label={`导入任务 ${index + 1}`}>
|
||||
<strong>{job.source?.name || `导入任务 ${index + 1}`}</strong>
|
||||
<p role="status">{["running","preparing"].includes(job.status) && job.source?.integrityVersion === 1 && !job.source.sha256 ? "正在校验源文件" : job.status === "preparing" ? "正在准备复制" : job.status === "running" ? "正在加入" : job.status === "paused" ? "已暂停" : job.status === "cancelled" ? "已停止" : job.status === "partial" ? "部分加入" : "已加入画布"} · {job.committedSourceKeys.length} 项{job.pending.length > 0 && `,${job.pending.length} 项等待`}</p>
|
||||
{job.source?.retirement?.purgedAt !== undefined && <small>本地源文件已清理;画布内容和撤销记录保留。</small>}
|
||||
{job.error && <p>{reason(job.error)}</p>}
|
||||
<div className={styles.importActions}>
|
||||
{["running","preparing"].includes(job.status) && <button onClick={() => control(engine.imports.pause(job.id))}>暂停导入</button>}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {importFiles,terminalImport,markImportFiles,prepareImportFileRetention,type FileRetirement} from './import-file-retention.ts';
|
||||
import {resourceRetentionPins,prepareResourceRetention} from './resource-retention.ts';
|
||||
import { encodeIndexedCopySnapshot, openIndexedCopySnapshot, type IndexedCopySnapshot } from './copy-snapshot-index.ts';
|
||||
import { decodeCopySnapshot, copyRegistrySignature } from './copy-snapshot.ts';
|
||||
@@ -63,7 +64,10 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const runClock: Clock = options.clock ?? { now: () => Date.now(), setTimer: (fn, delay) => setTimeout(fn, delay), clearTimer: handle => clearTimeout(handle as ReturnType<typeof setTimeout>) };
|
||||
const displays = new Set<() => void>();
|
||||
const displayPins=new Map<Id,number>(),collectionReservations=new Set<Id>(),durablyPurged=new Set<Id>([...(recovered?.runtime.resources.values()??[])].filter(resource=>resource.retirement?.purgedAt!==undefined).map(resource=>resource.id));
|
||||
let collection:Promise<{readonly markedIds:readonly Id[];readonly purgedIds:readonly Id[];readonly complete:boolean;readonly checkedRootRevision:number;readonly rootRevision:number;readonly durableRootRevision:number}>|undefined;
|
||||
type CollectionReport={readonly markedIds:readonly Id[];readonly purgedIds:readonly Id[];readonly complete:boolean;readonly checkedRootRevision:number;readonly rootRevision:number;readonly durableRootRevision:number};
|
||||
let sourceCollection:Promise<CollectionReport>|undefined;
|
||||
for(const [id,entry]of importFiles(recovered?.runtime.imports))if(entry.file.retirement?.purgedAt!==undefined)durablyPurged.add(id);
|
||||
let collection:Promise<CollectionReport>|undefined;
|
||||
const fileWrites = new Map<Id, Blob>(), digestTasks = new Map<Id, Promise<void>>();
|
||||
let staged: readonly ResourceRecord[] = [];
|
||||
let applying: RunApplyInput | undefined;
|
||||
@@ -79,7 +83,10 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
policy(command, document) { if (closing || closed || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; return options.policy?.(command, document) ?? { enabled: true }; },
|
||||
traversalPolicy(entry, direction) { return closing || closed || recovery ? { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] } : options.traversalPolicy?.(entry, direction) ?? { enabled: true }; },
|
||||
onListenerError: report,
|
||||
prepareRuntime:(_previous,next)=>prepareResourceRetention(next,options.registry,displayPins,collectionReservations,admissionPins(next)),
|
||||
prepareRuntime:(previous,next)=>{
|
||||
const runtime=prepareResourceRetention(next,options.registry,displayPins,collectionReservations,admissionPins(next));
|
||||
return prepareImportFileRetention(previous.runtime,runtime,()=>resourceRetentionPins({...next,runtime},options.registry,displayPins,admissionPins({...next,runtime})),collectionReservations,previous.document!==next.document||previous.history!==next.history||previous.runtime.ledger!==runtime.ledger||previous.runtime.resourcePins!==runtime.resourcePins);
|
||||
},
|
||||
prepareIntent: intent => applying && intent.origin==='user' ? {...intent,operationType:'run.apply',action:applying} : intent,
|
||||
nextOperationSequence(runtime, fallback) { let next = fallback; for (const record of runtime.ledger.operations.values()) next = Math.max(next, record.intent.sequence + 1); return next + staged.length; },
|
||||
reduceRuntime(previous, next, commit) {
|
||||
@@ -117,8 +124,9 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const task = (async () => {
|
||||
while (pending.size) {
|
||||
const [revision, snapshot] = pending.entries().next().value!;
|
||||
const files = [...fileWrites].filter(([id]) => snapshot.runtime.resources.has(id)||[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.fileId===id)).map(([id, bytes]) => ({ id, bytes }));
|
||||
const deleteFiles=[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt!==undefined&&!durablyPurged.has(resource.id)).map(resource=>resource.id);
|
||||
const sourceFiles=importFiles(snapshot.runtime.imports);
|
||||
const files = [...fileWrites].filter(([id]) => snapshot.runtime.resources.has(id)||sourceFiles.has(id)).map(([id, bytes]) => ({ id, bytes }));
|
||||
const deleteFiles=[...[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt!==undefined&&!durablyPurged.has(resource.id)).map(resource=>resource.id),...[...sourceFiles].filter(([id,entry])=>entry.file.retirement?.purgedAt!==undefined&&!durablyPurged.has(id)).map(([id])=>id)];
|
||||
const receipt = await options.storage.commit(leader, { image: encodeRecovery(snapshot), files,deleteFiles });
|
||||
if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt');
|
||||
durableRootRevision = revision; pending.delete(revision);
|
||||
@@ -164,6 +172,52 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
})();
|
||||
collection=task.finally(()=>{collection=undefined;});return collection;
|
||||
}
|
||||
function collectImportSources(settings:{retentionMs:number;maxFiles?:number}):Promise<CollectionReport> {
|
||||
assertWritable();if(options.storage.fileDeletion!=='atomic-v1')throw fault('resource_collection_unavailable');
|
||||
const retentionMs=settings.retentionMs,maxFiles=settings.maxFiles??64;
|
||||
if(!Number.isSafeInteger(retentionMs)||retentionMs<0||!Number.isSafeInteger(maxFiles)||maxFiles<1||maxFiles>128)throw fault('invalid_resource_collection');
|
||||
if(sourceCollection)return sourceCollection;
|
||||
const task=(async()=>{
|
||||
await persist();assertWritable();
|
||||
const now=runClock.now();if(!Number.isSafeInteger(now)||now<0)throw fault('invalid_resource_collection_clock');
|
||||
let root=editor.getSnapshot(),pins=resourceRetentionPins(root,options.registry,displayPins,admissionPins(root));
|
||||
const markedIds:Id[]=[],purgedIds:Id[]=[];
|
||||
const report=()=>Object.freeze({markedIds:Object.freeze(markedIds),purgedIds:Object.freeze(purgedIds),complete:pins.complete,checkedRootRevision:root.rootRevision,rootRevision:editor.getSnapshot().rootRevision,durableRootRevision});
|
||||
const candidates=()=>[...importFiles(root.runtime.imports)].filter(([id,entry])=>!pins.pins.has(id)&&!fileWrites.has(id)&&(!entry.current||terminalImport(root.runtime.imports!.get(entry.jobId)!.status)&&!importLoads.has(entry.jobId)));
|
||||
if(!pins.complete)return report();
|
||||
const fresh=candidates().filter(([,entry])=>!entry.file.retirement).slice(0,maxFiles);
|
||||
if(fresh.length){
|
||||
const markers=new Map(fresh.map(([id])=>[id,{version:1 as const,unreferencedAt:now}]));
|
||||
const result=editor.updateRuntime(previous=>markImportFiles(previous,markers),root.rootRevision);if(result.status==='rejected')throw fault(result.code,result.targets);
|
||||
markedIds.push(...markers.keys());await persist();assertWritable();
|
||||
}
|
||||
root=editor.getSnapshot();pins=resourceRetentionPins(root,options.registry,displayPins,admissionPins(root));if(!pins.complete)return report();
|
||||
const eligible=candidates().filter(([,entry])=>entry.file.retirement&&entry.file.retirement.purgedAt===undefined&&now-entry.file.retirement.unreferencedAt>=retentionMs).slice(0,maxFiles);
|
||||
if(eligible.length){
|
||||
const markers=new Map<Id,FileRetirement>(eligible.map(([id,entry])=>[id,{...entry.file.retirement!,purgedAt:now}]));
|
||||
for(const id of markers.keys())collectionReservations.add(id);
|
||||
const result=editor.updateRuntime(previous=>markImportFiles(previous,markers),root.rootRevision);
|
||||
if(result.status==='rejected'){for(const id of markers.keys())collectionReservations.delete(id);throw fault(result.code,result.targets);}
|
||||
await persist();purgedIds.push(...markers.keys());
|
||||
}
|
||||
return report();
|
||||
})();
|
||||
sourceCollection=task.finally(()=>{sourceCollection=undefined;});return sourceCollection;
|
||||
}
|
||||
async function openImportSource(jobId:Id,fileId?:Id) {
|
||||
if(closing||closed)throw fault('engine_closing');const job=getImport(jobId),file=fileId!==undefined?importFiles(new Map([[jobId,job]])).get(fileId)?.file:job.source;if(!file)throw fault('import_source_unavailable');
|
||||
if(collectionReservations.has(file.fileId))throw fault('resource_collection_in_progress',[file.fileId]);
|
||||
if(file.retirement?.purgedAt!==undefined)throw fault('import_source_retired',[file.fileId]);
|
||||
displayPins.set(file.fileId,(displayPins.get(file.fileId)??0)+1);let released=false;
|
||||
const release=()=>{if(released)return;released=true;displays.delete(release);const count=displayPins.get(file.fileId)??1;if(count<=1)displayPins.delete(file.fileId);else displayPins.set(file.fileId,count-1);};displays.add(release);
|
||||
try{
|
||||
if(file.retirement){const result=editor.updateRuntime(previous=>Object.freeze({...previous}));if(result.status==='rejected')throw fault(result.code,result.targets);}
|
||||
const bytes=fileWrites.get(file.fileId)??await options.storage.readFile(leader,file.fileId);
|
||||
if(closing||closed)throw fault('engine_closing');if(!bytes||bytes.size!==file.byteLength)throw fault('import_source_unavailable',[file.fileId]);
|
||||
if(file.sha256&&await sourceDigest(bytes)!==file.sha256)throw fault('import_source_changed',[file.fileId]);
|
||||
if(closing||closed)throw fault('engine_closing');return Object.freeze({bytes,release});
|
||||
}catch(error){release();if(closing||closed)throw fault('engine_closing');throw error;}
|
||||
}
|
||||
function mutate(reduce: (ledger: ReturnType<typeof createLedger>) => void) {
|
||||
const result = editor.updateRuntime(previous => { const ledger = createLedger(previous.ledger); reduce(ledger); return Object.freeze({ ...previous, ledger: ledger.getSnapshot() }); });
|
||||
if (result.status === 'rejected') throw fault(result.code, result.targets);
|
||||
@@ -398,7 +452,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const permission=options.policy?.({type:'changes.apply',changes:plan.changes},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
|
||||
const fileId=createId(),runtime=editor.getSnapshot().runtime;if(!fileId||fileWrites.has(fileId)||runtime.resources.has(fileId)||[...(runtime.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict');
|
||||
const stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([old,id])=>[`element:${old}`,id]),...Object.entries(plan.canvasIds).map(([old,id])=>[`canvas:${old}`,id])]);
|
||||
const next={...job,generation:job.generation+1,status:'running' as const,stableIds,sourceFingerprint:`local-file:${fileId}:${encoded.size}`,source:{...job.source!,kind:'duplicate-ndjson' as const,fileId,byteLength:encoded.size,sha256,integrityVersion:1 as const,copy:{sourceRevision:job.source!.copy!.sourceRevision,roots:plan.roots}}};delete next.preparation;delete next.error;
|
||||
const next={...job,generation:job.generation+1,status:'running' as const,stableIds,sourceFingerprint:`local-file:${fileId}:${encoded.size}`,source:{...job.source!,previousFiles:[...(job.source!.previousFiles??[]),{fileId:job.source!.fileId,byteLength:job.source!.byteLength,...(job.source!.sha256?{sha256:job.source!.sha256}:{})}],kind:'duplicate-ndjson' as const,fileId,byteLength:encoded.size,sha256,integrityVersion:1 as const,copy:{sourceRevision:job.source!.copy!.sourceRevision,roots:plan.roots}}};delete next.preparation;delete next.error;
|
||||
fileWrites.set(fileId,encoded);try{publishImport(freeze(next),job.generation);}catch(error){fileWrites.delete(fileId);throw error;}
|
||||
return{job:getImport(id),bytes:encoded,document:undefined,indexed:undefined};
|
||||
}
|
||||
@@ -443,9 +497,10 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
}
|
||||
const imports=Object.freeze({
|
||||
getJobs:()=>readMap(editor.getSnapshot().runtime.imports??new Map()),
|
||||
openSource:openImportSource,
|
||||
startNdjson(bytes:Blob,targetCanvasId:Id):Id{
|
||||
assertWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
|
||||
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||[...(current.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict');
|
||||
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict');
|
||||
const entry=canvas.parent?editor.getSnapshot().document.elements.get(canvas.parent.entryElementId)!:undefined;
|
||||
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const});
|
||||
fileWrites.set(fileId,bytes);
|
||||
@@ -467,7 +522,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const bytes=await encodeIndexedCopySnapshot(captured.document,controller.signal);assertWritable();if(controller.signal.aborted)throw fault('copy_aborted');
|
||||
const canvas=captured.document.canvases.get(target);if(!canvas)throw fault('canvas_not_loaded',[target]);
|
||||
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;
|
||||
if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||[...(current.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict');
|
||||
if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict');
|
||||
const entry=canvas.parent?captured.document.elements.get(canvas.parent.entryElementId)!:undefined;
|
||||
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'duplicate-snapshot',snapshotVersion:2,name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:4 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}});
|
||||
fileWrites.set(fileId,bytes);const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
|
||||
@@ -482,7 +537,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
scheduler.start(); prepareDigests(); runTracker.notify([...initial.runs.keys()]);
|
||||
for(const job of initial.imports?.values()??[])if(['running','preparing'].includes(job.status))void loadImport(job.id);
|
||||
return Object.freeze({
|
||||
editor, sessions, imports, collectResources,
|
||||
editor, sessions, imports, collectResources,collectImportSources,
|
||||
getResourcePins:()=>resourceRetentionPins(editor.getSnapshot(),options.registry,displayPins,admissionPins(editor.getSnapshot())),
|
||||
/** Read/preview under a scheduling hold. No request is rewritten or declared resolved here. */
|
||||
async prepareRecovery(): Promise<RecoverySession> {
|
||||
@@ -622,7 +677,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
if (!(file.bytes instanceof Blob) || !file.bytes.size || file.bytes.size > maxResourceBytes || !file.bytes.type.startsWith('image/') || typeof file.name !== 'string' || !file.name) throw fault('invalid_staged_file');
|
||||
return freeze({ id: createId(), operationId: createId(), name: file.name, mimeType: file.bytes.type, byteLength: file.bytes.size });
|
||||
});
|
||||
if (new Set(resources.flatMap(resource => [resource.id, resource.operationId])).size !== resources.length * 2 || resources.some(resource => editor.getSnapshot().runtime.resources.has(resource.id) || fileWrites.has(resource.id) || [...(editor.getSnapshot().runtime.imports?.values()??[])].some(job=>job.source?.fileId===resource.id))) throw fault('resource_identity_conflict');
|
||||
if (new Set(resources.flatMap(resource => [resource.id, resource.operationId])).size !== resources.length * 2 || resources.some(resource => editor.getSnapshot().runtime.resources.has(resource.id) || fileWrites.has(resource.id) || importFiles(editor.getSnapshot().runtime.imports).has(resource.id))) throw fault('resource_identity_conflict');
|
||||
staged = resources; resources.forEach((resource, index) => fileWrites.set(resource.id, files[index].bytes));
|
||||
try {
|
||||
const handles = freeze(resources.map(resource => ({ id: resource.id, src: options.assets!.sourceURL(resource.id) })));
|
||||
@@ -644,6 +699,11 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const result=editor.updateRuntime(previous=>{const resources=new Map(previous.resources),{retirement:_,...kept}=retained;resources.set(id,freeze(kept));return Object.freeze({...previous,resources:readMap(resources)});});
|
||||
if(result.status==='rejected')throw fault(result.code,result.targets);
|
||||
}
|
||||
if(!retained){
|
||||
const source=importFiles(editor.getSnapshot().runtime.imports).get(id)?.file;
|
||||
if(source?.retirement?.purgedAt!==undefined)throw fault('import_source_retired',[id]);
|
||||
if(source?.retirement){const result=editor.updateRuntime(previous=>Object.freeze({...previous}));if(result.status==='rejected')throw fault(result.code,result.targets);}
|
||||
}
|
||||
const bytes = fileWrites.get(id) ?? await options.storage.readFile(leader, id);
|
||||
if (closing || closed) throw fault('engine_closing');
|
||||
const resource=editor.getSnapshot().runtime.resources.get(id);
|
||||
@@ -660,7 +720,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
async settle() { await Promise.resolve();await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); },
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closing = true;await collection?.catch(report);digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
|
||||
closing = true;await Promise.allSettled([collection,sourceCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
|
||||
await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);
|
||||
recovery?.controller.abort();recovery?.release();
|
||||
for (const release of [...displays]) release();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {validateImportFileRetention,type FileRetirement,type PreviousImportFile} from './import-file-retention.ts';
|
||||
import { validateDuplicateCheckpoint, type DuplicateCheckpoint } from '../canvas-core/duplicate-checkpoint.ts';
|
||||
import type { Change, Id, Json, Result, Target, Point } from '../canvas-core/types.ts';
|
||||
import { selectImportGroups } from './import-groups.ts';
|
||||
@@ -8,7 +9,7 @@ export type ImportSource = { readonly fingerprint: string; readonly parserVersio
|
||||
export type ImportUnit = { readonly key: string; readonly dependencies: readonly string[]; readonly changes: readonly Change[]; readonly guard?: Json };
|
||||
export type ImportLocator = { readonly key: string; readonly cursor: number; readonly nextCursor: number; readonly dependencies: readonly string[]; readonly digest: string; readonly byteLength: number; readonly changeCount: number };
|
||||
export type ImportFailure = { readonly key: string; readonly cursor: number; readonly code: string; readonly dependencies?: readonly string[] };
|
||||
export type NativeImportSource = { readonly kind: 'native-ndjson' | 'duplicate-ndjson' | 'duplicate-snapshot' | 'duplicate-plan'; readonly snapshotVersion?:2;readonly plan?:DuplicateCheckpoint; readonly copy?: {readonly sourceRevision:number; readonly roots:readonly Id[]}; readonly integrityVersion?:1;readonly sha256?:string; readonly name?:string; readonly fileId: Id; readonly byteLength: number; readonly targetCanvasId: Id; readonly targetEntry?: {readonly id:Id;readonly incarnation:number} };
|
||||
export type NativeImportSource = { readonly kind: 'native-ndjson' | 'duplicate-ndjson' | 'duplicate-snapshot' | 'duplicate-plan'; readonly retirement?:FileRetirement;readonly previousFiles?:readonly PreviousImportFile[];readonly snapshotVersion?:2;readonly plan?:DuplicateCheckpoint; readonly copy?: {readonly sourceRevision:number; readonly roots:readonly Id[]}; readonly integrityVersion?:1;readonly sha256?:string; readonly name?:string; readonly fileId: Id; readonly byteLength: number; readonly targetCanvasId: Id; readonly targetEntry?: {readonly id:Id;readonly incarnation:number} };
|
||||
export type ImportBinding = {readonly kind:'element';readonly incarnation:number}|{readonly kind:'canvas';readonly entryElementId?:Id};
|
||||
export type ImportJob = {
|
||||
readonly version: 1 | 2 | 3; readonly id: Id; readonly projectId: Id; readonly sourceFingerprint: string; readonly parserVersion: number;
|
||||
@@ -29,6 +30,7 @@ export function validateImportJob(job: ImportJob): void {
|
||||
if (!Number.isSafeInteger(job.generation) || job.generation<0 || !Number.isSafeInteger(job.cursor) || job.cursor<0 || typeof job.eof!=='boolean' || !['preparing','paused','running','completed','partial','cancelled'].includes(job.status)) throw fault('invalid_import_job');
|
||||
if(job.source&&(!['native-ndjson','duplicate-ndjson','duplicate-snapshot','duplicate-plan'].includes(job.source.kind)||['duplicate-ndjson','duplicate-plan'].includes(job.source.kind)&&(!job.source.copy||!Number.isSafeInteger(job.source.copy.sourceRevision)||job.source.copy.sourceRevision<0||!Array.isArray(job.source.copy.roots)||!job.source.copy.roots.length||job.source.copy.roots.some(id=>typeof id!=='string'||!id))||job.source.name!==undefined&&(typeof job.source.name!=='string'||job.source.name.length>255)||!job.source.fileId||!job.source.targetCanvasId||!Number.isSafeInteger(job.source.byteLength)||job.source.byteLength<0||job.source.targetEntry&&(!job.source.targetEntry.id||!Number.isSafeInteger(job.source.targetEntry.incarnation)||job.source.targetEntry.incarnation<1)))throw fault('invalid_import_source');
|
||||
if(job.source){
|
||||
validateImportFileRetention(job.source,job.status);
|
||||
if(job.source.snapshotVersion!==undefined&&(job.source.snapshotVersion!==2||!['duplicate-snapshot','duplicate-plan'].includes(job.source.kind)))throw fault('copy_snapshot_interpreter_unavailable');
|
||||
if(job.source.integrityVersion!==undefined&&job.source.integrityVersion!==1)throw fault('import_source_integrity_interpreter_unavailable');
|
||||
if(job.source.sha256!==undefined&&(typeof job.source.sha256!=='string'||!(/^[a-f0-9]{64}$/.test(job.source.sha256))))throw fault('invalid_import_source');
|
||||
|
||||
@@ -52,8 +52,8 @@ export function encodeRecoveryValue(input:unknown):Json {
|
||||
}
|
||||
|
||||
/** Only codec-owned, validated immutable images qualify for the storage copy fast path. */
|
||||
export function sealRecoveryImage(formatVersion:1|2|3,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
|
||||
if(![1,2,3].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image');
|
||||
export function sealRecoveryImage(formatVersion:1|2|3|4,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
|
||||
if(![1,2,3,4].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image');
|
||||
const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image;
|
||||
}
|
||||
export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {importFiles} from './import-file-retention.ts';
|
||||
import {resourceRetentionPins} from './resource-retention.ts';
|
||||
import { sealRecoveryImage } from './recovery-encoding.ts';
|
||||
import { validateImportJob, type ImportJob } from './imports.ts';
|
||||
@@ -23,7 +24,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage {
|
||||
operations: [...snapshot.operations],
|
||||
runtime: { imports:[...(snapshot.runtime.imports??[])], recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } },
|
||||
};
|
||||
return sealRecoveryImage([...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload);
|
||||
return sealRecoveryImage([...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload);
|
||||
}
|
||||
type ObjectValue = Record<string, unknown>;
|
||||
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
|
||||
@@ -86,7 +87,7 @@ function readHistory(history:ObjectValue,operations:ReadonlyMap<Id,OperationInte
|
||||
return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries});
|
||||
}
|
||||
export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSnapshot & { readonly rootRevision: number; readonly runtime: RuntimeState } {
|
||||
if (![1,2,3].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (![1,2,3,4].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
integer(image.rootRevision); integer(image.localRevision);
|
||||
if (image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
|
||||
// Own the parsed records; mutating a caller's RecoveryImage must not mutate live state.
|
||||
@@ -163,7 +164,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
const raw = object(value);
|
||||
if (string(raw.id) !== id || string(raw.operationId) === id) throw fault('corrupt_recovery_image');
|
||||
string(raw.name); string(raw.mimeType); integer(raw.byteLength, 1);
|
||||
if(raw.retirement!==undefined){if(image.formatVersion!==3)throw fault('unsupported_recovery_version');const marker=object(raw.retirement);if(marker.version!==1)throw fault('resource_retirement_interpreter_unavailable');integer(marker.unreferencedAt);if(marker.purgedAt!==undefined&&integer(marker.purgedAt)<(marker.unreferencedAt as number))throw fault('corrupt_recovery_image');}
|
||||
if(raw.retirement!==undefined){if(image.formatVersion<3)throw fault('unsupported_recovery_version');const marker=object(raw.retirement);if(marker.version!==1)throw fault('resource_retirement_interpreter_unavailable');integer(marker.unreferencedAt);if(marker.purgedAt!==undefined&&integer(marker.purgedAt)<(marker.unreferencedAt as number))throw fault('corrupt_recovery_image');}
|
||||
if (raw.digest !== undefined && !/^[a-f0-9]{64}$/.test(string(raw.digest))) throw fault('corrupt_recovery_image');
|
||||
const operation = records.get(raw.operationId as string)?.intent;
|
||||
if (operation?.operationType !== 'asset.upload' || operation.resourceId !== id) throw fault('corrupt_recovery_image');
|
||||
@@ -226,6 +227,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
return freeze({...value as RunRecord,outputEvents:array(raw.outputEvents ?? []) as never});
|
||||
});
|
||||
const imports=table<ImportJob>(runtime.imports??[],(value,id)=>{const job=value as ImportJob;validateImportJob(job);if(job.id!==id||job.projectId!==document.projectId)throw fault('corrupt_recovery_image');return freeze(job);});
|
||||
for(const [id,entry]of importFiles(imports)){if(resources.has(id))throw fault('import_file_identity_conflict',[id]);if((entry.file.retirement||imports.get(entry.jobId)?.source?.previousFiles?.length)&&image.formatVersion!==4)throw fault('unsupported_recovery_version');}
|
||||
const restoredLedger: LedgerSnapshot = Object.freeze({ generation, durableGeneration, operations: records, requests });
|
||||
const snapshot=Object.freeze({ revision: image.localRevision, rootRevision: image.rootRevision, document, history:restoredHistory, operations,
|
||||
runtime: Object.freeze({ imports, recoveries, runs, resources, resourcePins, ledger: restoredLedger, acknowledged, versions, receipts, conflicts: table(runtime.conflicts, string) }),
|
||||
|
||||
@@ -14,7 +14,7 @@ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,
|
||||
for(const [id,owners]of base.pins)for(const owner of owners)if(owner.startsWith('element:'))add(id,`acknowledged:${owner}`);
|
||||
// Sources and server run envelopes are not all interpreted resource schemas yet.
|
||||
// Pin the whole known local depot rather than claiming opaque task data contains no references.
|
||||
for(const job of runtime.imports?.values()??[])if(!['completed','partial','cancelled'].includes(job.status))for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`import:${job.id}`);
|
||||
for(const job of runtime.imports?.values()??[])if(!['completed','partial','cancelled'].includes(job.status)){if(job.source)add(job.source.fileId,`import-source:${job.id}`);for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`import:${job.id}`);}
|
||||
for(const run of runtime.runs.values())for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`run:${run.id}`);
|
||||
for(const [owner,ids]of temporary)for(const id of ids)add(id,owner);
|
||||
for(const [id,count]of displays)if(count>0)add(id,'display');
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Json } from '../canvas-core/types.ts';
|
||||
import { clone, equal, fault, freeze, validJson } from '../canvas-core/values.ts';
|
||||
|
||||
export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string };
|
||||
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
|
||||
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
|
||||
export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] };
|
||||
export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise<void> };
|
||||
export type StoragePort = {
|
||||
@@ -34,7 +34,7 @@ const keyFor = (scope: StorageScope) => {
|
||||
return JSON.stringify([scope.accountId, scope.workspaceId, scope.projectId]);
|
||||
};
|
||||
function validateImage(image: RecoveryImage) {
|
||||
if (![1,2,3].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (![1,2,3,4].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (!Number.isSafeInteger(image.rootRevision) || image.rootRevision < 0 || !Number.isSafeInteger(image.localRevision) || image.localRevision < 0 || image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
|
||||
}
|
||||
const requestValue = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
|
||||
@@ -134,9 +134,13 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?:
|
||||
// Detach mutable callers and descriptors before the first await, including queued commits.
|
||||
const image=isSealedRecoveryImage(batch.image)?batch.image:freeze(clone(batch.image)),files=(batch.files??[]).map(file=>({id:file.id,bytes:file.bytes})),deleteFiles=[...(batch.deleteFiles??[])];if(deleteFiles.length>128||new Set(deleteFiles).size!==deleteFiles.length||deleteFiles.some(id=>typeof id!=='string'||!id||files.some(file=>file.id===id)))throw fault('invalid_file_deletion');if(!isSealedRecoveryImage(image))validateImage(image);
|
||||
if(deleteFiles.length){
|
||||
const payload=image.payload as {runtime?:{resources?:[string,{retirement?:{version:number;unreferencedAt:number;purgedAt?:number}}][]}};
|
||||
const rows=payload?.runtime?.resources;
|
||||
if(image.formatVersion!==3||!Array.isArray(rows)||deleteFiles.some(id=>!rows.some(([key,resource])=>key===id&&resource.retirement?.version===1&&Number.isSafeInteger(resource.retirement.unreferencedAt)&&resource.retirement.unreferencedAt>=0&&Number.isSafeInteger(resource.retirement.purgedAt)&&resource.retirement.purgedAt!>=resource.retirement.unreferencedAt)))throw fault('invalid_file_deletion');
|
||||
type FileProof={fileId?:string;retirement?:{version:number;unreferencedAt:number;purgedAt?:number}};
|
||||
const payload=image.payload as {runtime?:{resources?:[string,FileProof][];imports?:[string,{status:string;source?:FileProof&{previousFiles?:FileProof[]}}][]}};
|
||||
const proofs=new Map<string,FileProof>(),owned=new Set<string>();
|
||||
const own=(id:string,file:FileProof,eligible:boolean)=>{if(typeof id!=='string'||!id||owned.has(id))throw fault('invalid_file_deletion');owned.add(id);if(eligible)proofs.set(id,file);};
|
||||
for(const [id,resource]of payload?.runtime?.resources??[])own(id,resource,true);
|
||||
if(image.formatVersion===4)for(const [,job]of payload?.runtime?.imports??[]){if(job.source){own(job.source.fileId!,job.source,['completed','partial','cancelled'].includes(job.status));for(const file of job.source.previousFiles??[])own(file.fileId!,file,true);}}
|
||||
if(image.formatVersion<3||deleteFiles.some(id=>{const marker=proofs.get(id)?.retirement;return !marker||marker.version!==1||!Number.isSafeInteger(marker.unreferencedAt)||marker.unreferencedAt<0||!Number.isSafeInteger(marker.purgedAt)||marker.purgedAt!<marker.unreferencedAt;}))throw fault('invalid_file_deletion');
|
||||
}
|
||||
check(leader);
|
||||
const task=(writeTails.get(leader)??Promise.resolve()).catch(()=>{}).then(async()=>{
|
||||
|
||||
@@ -302,6 +302,21 @@ async function atomicResourceDeletionCase(name,assert){
|
||||
}finally{await other?.release();await leader?.release();storage.close();indexedDB.deleteDatabase(database);}
|
||||
}
|
||||
|
||||
async function importSourceCollectionCase(name,assert){
|
||||
const database=`${name}-import-source-gc`,storage=createBrowserStorage({name:database,checkpointEvery:3}),scope={accountId:'account',workspaceId:'workspace',projectId:'project'},registry=createRegistry(builtinComponents()),original=createDocument('project',[{id:'P',parent:null}],[]),receipts=new Map();let engine,owner,server=original,version=1,now=1000;
|
||||
const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{P:version}}),async submit(request){const {intent}=JSON.parse(request.body);server=applyChanges(server,intent.changes);receipts.set(request.id,{document:server,versions:new Map([['P',++version]])});return{outcome:'applied'};},async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},canonical:request=>receipts.get(request.id)};
|
||||
const options={scope,registry,document:original,versions:new Map([['P',1]]),storage:{...storage,commit:(leader,batch)=>{owner=leader;return storage.commit(leader,batch);}},persistence:port,clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}};
|
||||
try{
|
||||
engine=await openProjectEngine(options);const line={kind:'element',key:'text',record:{id:'text',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'$target',data:{title:'text',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}},id=engine.imports.startNdjson(new Blob([JSON.stringify(line)+'\nnot-json']),'P');await engine.settle();const job=engine.imports.getJobs().get(id),fileId=job.source.fileId;assert(job.status==='partial','partial source fixture did not finish');const document=engine.getSnapshot().document,history=engine.getSnapshot().history;
|
||||
await engine.collectImportSources({retentionMs:100});now+=100;const nativeDelete=IDBObjectStore.prototype.delete;let injected=false;
|
||||
IDBObjectStore.prototype.delete=function(key){const request=nativeDelete.call(this,key);if(!injected&&this.name==='files'&&Array.isArray(key)&&key[1]===fileId){injected=true;throw Error('source_gc_abort');}return request;};
|
||||
let failure;try{await engine.collectImportSources({retentionMs:100});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.delete=nativeDelete;}
|
||||
assert(injected&&failure==='source_gc_abort','source GC did not reach an IDB deletion fault');assert((await storage.readFile(owner,fileId)).size===job.source.byteLength,'source bytes vanished after aborted GC');const soft=await storage.recover(owner);assert(soft.formatVersion===4&&soft.payload.runtime.imports[0][1].source.retirement.purgedAt===undefined,'aborted source GC advanced the purge cut');
|
||||
await engine.persist();assert(await storage.readFile(owner,fileId)===undefined,'source deletion retry did not remove bytes');const cut=await storage.recover(owner);assert(cut.formatVersion===4&&decodeRecovery(cut,registry).runtime.imports.get(id).source.retirement.purgedAt===1100,'source purge and recovery marker disagree');assert(engine.getSnapshot().document===document&&engine.getSnapshot().history===history,'source GC changed content/history');
|
||||
let oldReader;try{decodeRecovery({...cut,formatVersion:3},registry);}catch(error){oldReader=error.code;}assert(oldReader==='unsupported_recovery_version','source retirement was accepted as v3');await engine.close();engine=await openProjectEngine(options);await engine.imports.resume(id);await engine.settle();assert(engine.imports.getJobs().get(id).status==='partial'&&engine.imports.getJobs().get(id).failures.length===1,'reopening a retired source lost its failure report');let retired;try{await engine.imports.openSource(id);}catch(error){retired=error.code;}assert(retired==='import_source_retired','retired source was silently reinterpreted or fetched as an asset');assert(engine.editor.history.undo().status==='applied','undo depended on purged source');await engine.settle();assert(engine.getSnapshot().document.elements.size===0,'undo retained imported data');assert(engine.editor.history.redo().status==='applied','redo depended on purged source');await engine.settle();assert(engine.getSnapshot().document.elements.size===1,'redo did not restore imported content');
|
||||
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
|
||||
}
|
||||
|
||||
async function resourceRecoveryCase(name, assert) {
|
||||
const scope = { accountId: crypto.randomUUID(), workspaceId: crypto.randomUUID(), projectId: crypto.randomUUID() };
|
||||
const registry = createRegistry(builtinComponents()), document = createDocument(scope.projectId, [{ id: scope.projectId, parent: null }], []);
|
||||
@@ -592,7 +607,7 @@ export async function run() {
|
||||
await storage.commit(leader, { image: image(1) });
|
||||
await rejects(() => storage.commit(leader, { image: { ...image(1), payload: null } }), 'storage_revision_conflict');
|
||||
await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_revision_gap');
|
||||
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 4 } }), 'unsupported_recovery_version');
|
||||
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 5 } }), 'unsupported_recovery_version');
|
||||
results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject');
|
||||
|
||||
// The first put succeeds before a later duplicate resource aborts the whole transaction.
|
||||
@@ -635,6 +650,8 @@ export async function run() {
|
||||
results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit');
|
||||
await atomicResourceDeletionCase(name,assert);
|
||||
results.push('atomic local file collection rolls back injected failure after IDB delete, captures descriptors, rejects missing markers and survives reopening');
|
||||
await importSourceCollectionCase(name,assert);
|
||||
results.push('terminal import source GC atomically rolls back a post-delete fault, retries its v4 cut, preserves partial reports and reopens with undo/redo independent of source bytes');
|
||||
await resourceRecoveryCase(name, assert);
|
||||
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
|
||||
await digestWorkerCase(assert);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.ts';
|
||||
import {markImportFiles,importFiles,prepareImportFileRetention} from '../../src/lib/canvas-runtime/import-file-retention.ts';
|
||||
const job={...createImportJob({id:'job',projectId:'P',sourceFingerprint:'source',parserVersion:1,source:{kind:'native-ndjson',fileId:'source',byteLength:10,targetCanvasId:'P'}}),status:'cancelled'},runtime={imports:new Map([['job',job]]),resources:new Map()},coverage=()=>({complete:true,pins:new Map()});
|
||||
test('source retention marks are immutable metadata with monotonic job generations and per-file clearing',()=>{
|
||||
const next=markImportFiles(runtime,new Map([['source',{version:1,unreferencedAt:100}]]));assert.notEqual(next,runtime);assert.equal(job.source.retirement,undefined);assert.equal(next.imports.get('job').generation,1);assert.ok(Object.isFrozen(next.imports.get('job').source.retirement));validateImportJob(next.imports.get('job'));assert.equal(markImportFiles(next,new Map([['source',{version:1,unreferencedAt:100}]])),next);
|
||||
const pinned=prepareImportFileRetention(next,next,()=>({complete:true,pins:new Map([['source',new Set(['export'])]])}),new Set());assert.equal(pinned.imports.get('job').source.retirement,undefined);assert.equal(pinned.imports.get('job').generation,2);assert.equal(next.imports.get('job').source.retirement.unreferencedAt,100);
|
||||
const incomplete=prepareImportFileRetention(next,next,()=>({complete:false,pins:new Map()}),new Set());assert.equal(incomplete.imports.get('job').source.retirement,undefined);
|
||||
});
|
||||
test('source ownership, archive direction and completed purge metadata cannot be silently rewritten',()=>{
|
||||
const archived={...job,source:{...job.source,fileId:'next',previousFiles:[{fileId:'source',byteLength:10}]}};assert.equal(importFiles(new Map([['job',archived]])).size,2);assert.equal(prepareImportFileRetention(runtime,{...runtime,imports:new Map([['job',archived]])},coverage,new Set()).imports.get('job'),archived);
|
||||
assert.throws(()=>prepareImportFileRetention(runtime,{...runtime,imports:new Map()},coverage,new Set()),/import_file_identity_conflict/);assert.throws(()=>prepareImportFileRetention(runtime,{...runtime,resources:new Map([['source',{}]])},coverage,new Set()),/import_file_identity_conflict/);
|
||||
const retired=markImportFiles(runtime,new Map([['source',{version:1,unreferencedAt:100,purgedAt:200}]]));assert.throws(()=>prepareImportFileRetention(retired,runtime,coverage,new Set()),/import_source_retired/);
|
||||
assert.throws(()=>importFiles(new Map([['job',job],['other',{...job,id:'other'}]])),/import_file_identity_conflict/);
|
||||
const resumed={...job,status:'running',source:{...job.source,retirement:{version:1,unreferencedAt:100,purgedAt:200}}};assert.throws(()=>validateImportJob(resumed),/invalid_import_file_retirement/);
|
||||
for(const retirement of [{version:2,unreferencedAt:1},{version:1,unreferencedAt:-1},{version:1,unreferencedAt:2,purgedAt:1}])assert.throws(()=>validateImportJob({...job,source:{...job.source,retirement}}),/invalid_import_file_retirement/);
|
||||
});
|
||||
@@ -12,7 +12,7 @@ const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);retu
|
||||
function fixture(initial=original){
|
||||
let image=null,leader,next=0,server=initial;const versions=new Map([...initial.canvases.keys()].map(id=>[id,1]));const files=new Map(),requests=[],receipts=new Map(),errors=[];
|
||||
const f={files,requests,errors,gate:null,broken:false,
|
||||
storage:{async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},close(){}},
|
||||
storage:{fileDeletion:'atomic-v1',async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},close(){}},
|
||||
port:{compile(intent,requestId,snapshot){return{payload:{requestId,intent},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){requests.push(request);if(f.gate)await f.gate.promise;const{intent}=JSON.parse(request.body);if(!receipts.has(request.id)){server=applyChanges(server,intent.changes);for(const canvasId of intent.canvasIds)versions.set(canvasId,(versions.get(canvasId)??0)+1);receipts.set(request.id,{document:server,versions:new Map(versions)});}return{outcome:'applied',canonical:{requestId:request.id}};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:{requestId:request.id}}:{outcome:'unresolved'};},canonical(request){return receipts.get(request.id);},async read(){return{document:server,versions:new Map(versions)};}},
|
||||
image:()=>image,server:()=>server,open:(extra={})=>openProjectEngine({scope,registry,document:initial,versions:new Map([...initial.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,createId:()=>`identity-${++next}`,onError:error=>errors.push(error),...extra})};return f;
|
||||
}
|
||||
@@ -241,3 +241,52 @@ test('closing a project aborts its injected digest without closing another proje
|
||||
const {createDigestPool}=await import('../../src/lib/canvas-runtime/digest-pool.ts'),entered=deferred();let first=true;const pool=createDigestPool({concurrency:1,execute:async(bytes,signal)=>{if(first){first=false;entered.resolve();await new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(Error('stopped')),{once:true}));}return importSourceDigest(bytes,signal);}}),a=fixture(),b=fixture();let ea=await a.open({digests:pool}),eb=await b.open({digests:pool});
|
||||
try{const aid=ea.imports.startNdjson(file([node('A')]),'canvas');await entered.promise;const bid=eb.imports.startNdjson(file([node('B')]),'canvas');await ea.close();await eb.settle();assert.equal(eb.imports.getJobs().get(bid).status,'completed');assert.equal(eb.getSnapshot().document.elements.size,1);assert.equal(a.errors.length,0);assert.equal(b.errors.length,0);ea=await a.open();await ea.settle();assert.equal(ea.imports.getJobs().get(aid).status,'completed');assert.equal(ea.getSnapshot().document.elements.size,1);}finally{await ea.close();await eb.close();pool.close();}
|
||||
});
|
||||
|
||||
const retentionClock=()=>{let now=1000;return{now:()=>now,advance:ms=>{now+=ms;},setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)};};
|
||||
test('terminal import source collection preserves every imported record, undo unit and pending request across v4 reopening',async()=>{
|
||||
const f=fixture(),clock=retentionClock(),gate=deferred();f.gate=gate;let engine=await f.open({clock});try{
|
||||
const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');await engine.persist();const job=engine.imports.getJobs().get(id),fileId=job.source.fileId,document=engine.getSnapshot().document,history=engine.getSnapshot().history;
|
||||
const marked=await engine.collectImportSources({retentionMs:100});assert.deepEqual(marked.markedIds,[fileId]);assert.deepEqual(marked.purgedIds,[]);assert.ok(f.files.has(fileId));assert.equal(f.image().formatVersion,4);clock.advance(100);
|
||||
const result=await engine.collectImportSources({retentionMs:100});assert.deepEqual(result.purgedIds,[fileId]);assert.equal(f.files.has(fileId),false);assert.equal(engine.getSnapshot().document,document);assert.equal(engine.getSnapshot().history,history);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].some(record=>record.phase!=='resolved'));
|
||||
const frozen=[...engine.getSnapshot().runtime.ledger.requests.values()].map(request=>[request.id,request.body]);decodeRecovery(f.image(),registry);for(const formatVersion of [1,2,3])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/);
|
||||
gate.resolve();await engine.settle();await engine.close();engine=await f.open({clock});await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,2);for(const [id,body]of frozen)assert.equal(engine.getSnapshot().runtime.ledger.requests.get(id).body,body);await assert.rejects(engine.imports.openSource(id),/import_source_retired/);
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(f.errors.length,0);
|
||||
const bad={...node('bad').record,incarnation:1,ownerCanvasId:'canvas',componentId:'image',data:{title:'bad',text:'',assetId:fileId}};assert.equal(engine.editor.dispatch({type:'element.create',element:bad}).code,'import_source_retired');assert.equal(engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map([['late-export',[fileId]]])})).code,'import_source_retired');
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
test('paused source remains recoverable while partial and cancelled sources become collectible without reviving content',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{
|
||||
const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const source=engine.imports.getJobs().get(id).source;assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);assert.ok(f.files.has(source.fileId));const handle=await engine.imports.openSource(id);assert.equal(await handle.bytes.text(),await file([node('one'),node('two'),node('three')]).text());handle.release();
|
||||
await engine.imports.cancel(id);await engine.settle();const partial=engine.imports.startNdjson(file([node('valid'),'not json']),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(partial).status,'partial');const expected=new Set([source.fileId,engine.imports.getJobs().get(partial).source.fileId]);assert.deepEqual(new Set((await engine.collectImportSources({retentionMs:0})).purgedIds),expected);const size=engine.getSnapshot().document.elements.size;await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.imports.resume(partial);await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.imports.getJobs().get(partial).failures.length,1);assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
test('opening a terminal source pins before I/O, clears its soft marker and restarts the full retention interval',async()=>{
|
||||
const f=fixture(),clock=retentionClock(),engine=await f.open({clock}),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{
|
||||
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId;await engine.collectImportSources({retentionMs:100});clock.advance(200);f.storage.readFile=async(...args)=>{if(args[1]===fileId){entered.resolve();await gate.promise;}return read(...args);};
|
||||
const opening=engine.imports.openSource(id);await entered.promise;assert.equal(engine.imports.getJobs().get(id).source.retirement,undefined);assert.ok(engine.getResourcePins().pins.get(fileId).has('display'));assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);gate.resolve();const handle=await opening;handle.release();handle.release();const result=await engine.collectImportSources({retentionMs:100});assert.deepEqual(result.markedIds,[fileId]);assert.deepEqual(result.purgedIds,[]);assert.equal(engine.imports.getJobs().get(id).source.retirement.unreferencedAt,1200);
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
test('failed source deletion retains byte identity and blocks late reads until the same purge cut becomes durable',async()=>{
|
||||
const f=fixture(),engine=await f.open(),commit=f.storage.commit;let fail=false;try{
|
||||
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('source disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectImportSources({retentionMs:0}),/source disk failure/);assert.ok(f.files.has(fileId));await assert.rejects(engine.imports.openSource(id),/resource_collection_in_progress/);const root=engine.getSnapshot(),job=root.runtime.imports.get(id);assert.equal(engine.editor.updateRuntime(previous=>{const jobs=new Map(previous.imports);jobs.set(id,{...job,status:'running',source:{...job.source,retirement:undefined}});return{...previous,imports:jobs};}).status,'rejected');assert.equal(engine.getSnapshot(),root);
|
||||
fail=false;await engine.persist();assert.equal(f.files.has(fileId),false);await assert.rejects(engine.imports.openSource(id),/import_source_retired/);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:job.stableIds['element:one'],incarnation:1},patch:{title:'still editable'}}).status,'applied');await engine.settle();
|
||||
}finally{fail=false;await engine.close();}
|
||||
});
|
||||
|
||||
test('legacy snapshot conversion records its previous file, then collects both generations without losing copied references',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{
|
||||
await seedCopy(engine);const captured=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.imports.pause(id);await engine.settle();await engine.close();const image=f.image(),job=image.payload.runtime.imports.find(([key])=>key===id)[1],bytes=await encodeCopySnapshot(captured,new AbortController().signal),oldFile=job.source.fileId;f.files.set(oldFile,bytes);job.source.byteLength=bytes.size;job.source.sha256=await importSourceDigest(bytes);job.sourceFingerprint=`local-file:${oldFile}:${bytes.size}`;delete job.source.snapshotVersion;job.preparation={version:1,registrySignature:job.preparation.registrySignature,selection:[{id:'F',incarnation:1}],worldOffset:{x:32,y:32},allocationIds:[],steps:0};decodeRecovery(image,registry);
|
||||
engine=await f.open();await engine.imports.resume(id);await engine.imports.pause(id);await engine.settle();const converted=engine.imports.getJobs().get(id),newFile=converted.source.fileId;assert.equal(converted.status,'paused');assert.equal(converted.source.kind,'duplicate-ndjson');assert.notEqual(newFile,oldFile);assert.equal(converted.source.previousFiles.length,1);assert.equal(converted.source.previousFiles[0].fileId,oldFile);assert.equal(f.image().formatVersion,4);assert.ok(f.files.has(oldFile)&&f.files.has(newFile));const held=await engine.imports.openSource(id,oldFile);assert.equal(held.bytes.size,bytes.size);assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);held.release();assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[oldFile]);assert.ok(f.files.has(newFile));await engine.imports.resume(id);await engine.settle();const completed=engine.imports.getJobs().get(id);assert.equal(completed.status,'completed');const copied=completed.stableIds['element:F'];assert.ok(engine.getSnapshot().document.elements.has(copied));
|
||||
const result=await engine.collectImportSources({retentionMs:0});assert.deepEqual(result.purgedIds,[newFile]);assert.equal(f.files.has(oldFile),false);assert.equal(f.files.has(newFile),false);decodeRecovery(f.image(),registry);await engine.close();engine=await f.open();assert.equal(engine.getSnapshot().document.elements.get(completed.stableIds['element:A']).data.assetId,'shared');assert.equal(engine.getSnapshot().document.elements.get(completed.stableIds['element:incoming']).source.elementId,'C');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has(copied),false);assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
test('source read handles verify bound bytes and never publish a result after project close',async()=>{
|
||||
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{
|
||||
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId,bytes=f.files.get(fileId),text=await bytes.text();f.files.set(fileId,new Blob([text.replace('one','two')]));await assert.rejects(engine.imports.openSource(id),/import_source_changed/);assert.equal(engine.getResourcePins().pins.has(fileId),false);f.files.set(fileId,bytes);
|
||||
f.storage.readFile=async(owner,key)=>{const value=await read(owner,key);if(key===fileId){entered.resolve();await gate.promise;}return value;};const opening=engine.imports.openSource(id);await entered.promise;await engine.close();gate.resolve();await assert.rejects(opening,/engine_closing/);assert.equal(engine.getResourcePins().pins.has(fileId),false);
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
@@ -24,5 +24,5 @@ test('cached subtree height cannot bypass the depth bound and omitted object fie
|
||||
const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n}));
|
||||
});
|
||||
test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{
|
||||
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.throws(()=>sealRecoveryImage(4,1,1,{}),/corrupt_recovery_image/);
|
||||
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.throws(()=>sealRecoveryImage(5,1,1,{}),/corrupt_recovery_image/);
|
||||
});
|
||||
|
||||
@@ -89,6 +89,10 @@ try {
|
||||
await cp(join(source, 'public'), join(directory, 'public'), { recursive: true });
|
||||
if(!performanceMode){
|
||||
await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx'));
|
||||
// A fixture Host action exercises the public engine API; no debug entry point ships in the app.
|
||||
const importsFile=join(directory,'src/components/canvas/headless-imports.tsx'),importsSource=await readFile(importsFile,'utf8');
|
||||
await writeFile(importsFile,importsSource.replace('import { useCallback,','import { useEffect, useCallback,').replace(' const subscribe =',` useEffect(()=>{const host=globalThis as typeof globalThis&{__headlessCollectSources?:()=>ReturnType<ProjectEngine['collectImportSources']>};const collect=()=>engine.collectImportSources({retentionMs:0});host.__headlessCollectSources=collect;return()=>{if(host.__headlessCollectSources===collect)delete host.__headlessCollectSources;};},[engine]);
|
||||
const subscribe =`));
|
||||
const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";').replace('extensions=[]','extensions=[fixtureExtension]'));
|
||||
}
|
||||
|
||||
@@ -262,6 +266,12 @@ try {
|
||||
await evaluate(`[...document.querySelectorAll('summary')].find(summary=>summary.textContent.includes('条未加入记录')).click()`);await until(`document.body.innerText.includes('记录格式错误')`,'import failure reason was hidden');
|
||||
const importScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-integration.png',Buffer.from(importScreenshot.data,'base64'));releaseImport();
|
||||
for(let i=0;i<200&&documents.get(root).elements.size!==beforeImport+1;i++)await pause(20);assert.equal(documents.get(root).elements.size,beforeImport+1);await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('部分加入')`,'import task and content did not recover after reload');
|
||||
const beforeSourceCollection=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
|
||||
await until(`typeof window.__headlessCollectSources==='function'`,'fixture Host action was not installed');const sourceCollection=await evaluate(`window.__headlessCollectSources()`);assert.equal(sourceCollection.purgedIds.length,1);
|
||||
await evaluate(`(()=>{const details=document.querySelector('[aria-label="画布导入"] details');if(!details.open)details.querySelector('summary').click();})()`);
|
||||
await until(`document.body.innerText.includes('本地源文件已清理')`,'source collection status was not displayed');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeSourceCollection,'source GC created a document operation');
|
||||
const sourceScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-source-collection.png',Buffer.from(sourceScreenshot.data,'base64'));await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('本地源文件已清理')`,'retired source status/content did not recover');
|
||||
console.log('PASS Host-triggered source collection displays its status, survives reload and creates zero model operations');
|
||||
await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import undo did not remove its successful content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import recovered an undone record');
|
||||
console.log('PASS actual NDJSON import displays before backend ACK, reports a failed record, restores task/source state and never revives undone content');
|
||||
await evaluate(`document.querySelector('.react-flow__node[data-id=b]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
|
||||
|
||||
Reference in New Issue
Block a user