feat(canvas): persist and execute deep duplicate tasks
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { planDuplicateAsync } from './duplicate.ts';
|
||||
import { encodeDuplicateSource, prepareDuplicateImport, assertDuplicateExternal } from './import-duplicate.ts';
|
||||
import { createImportJob, createImportRunner, createNdjsonImportSource, type ImportJob } from './imports.ts';
|
||||
import { assertNativeImportDependencies, assertNativeImportTarget, prepareNativeImport } from './import-native.ts';
|
||||
import { elisionBatchLimit, findElision } from './elision.ts';
|
||||
import type { Document, Id, Registry, Result, RunInput, RunApplyInput, RunCancelInput, Json, OperationIntent, Data } from '../canvas-core/types.ts';
|
||||
import type { Document, Id, Registry, Result, RunInput, RunApplyInput, RunCancelInput, Target, Point, Json, OperationIntent, Data } from '../canvas-core/types.ts';
|
||||
import { createEditor, type EditorOptions, type Transaction } from '../canvas-core/editor.ts';
|
||||
import { createDocument } from '../canvas-core/document.ts';
|
||||
import { createSessions } from '../canvas-core/gestures.ts';
|
||||
@@ -55,6 +57,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
let staged: readonly ResourceRecord[] = [];
|
||||
let applying: RunApplyInput | undefined;
|
||||
let importing: { job: ImportJob; expectedGeneration: number } | undefined;
|
||||
const copyPreparations=new Map<AbortController,Promise<Id>>();
|
||||
const importRunners=new Map<Id,ReturnType<typeof createImportRunner>>(),importLoads=new Map<Id,Promise<void>>(),importHolds=new Map<symbol,Map<Id,()=>void>>();
|
||||
let recovery: { controller: AbortController; release(): void; cancel():Promise<void> } | undefined;
|
||||
const assertWritable = () => { if(closing||closed)throw fault('engine_closing');if(recovery)throw fault('recovery_in_progress'); };
|
||||
@@ -321,18 +324,18 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
if(importRunners.has(id)){void importRunners.get(id)!.start();return Promise.resolve();}
|
||||
const expectedFingerprint=getImport(id).sourceFingerprint;
|
||||
const task=(async()=>{
|
||||
const job=getImport(id),source=job.source;if(!source||source.kind!=='native-ndjson'||job.parserVersion!==1)throw fault('import_interpreter_unavailable');
|
||||
const job=getImport(id),source=job.source;if(!source||!['native-ndjson','duplicate-ndjson'].includes(source.kind)||job.parserVersion!==1)throw fault('import_interpreter_unavailable');
|
||||
const bytes=fileWrites.get(source.fileId)??await options.storage.readFile(leader,source.fileId);
|
||||
if(closing||closed)return;
|
||||
const latest=getImport(id);if(latest.sourceFingerprint!==job.sourceFingerprint||latest.source?.fileId!==source.fileId||['completed','partial','cancelled'].includes(latest.status))return;
|
||||
if(!bytes)throw fault('import_source_unavailable');if(bytes.size!==source.byteLength)throw fault('import_source_changed');
|
||||
const runner=createImportRunner({optimisticLocal:true,source:createNdjsonImportSource({bytes,fingerprint:job.sourceFingerprint}),getJob:()=>getImport(id),createId,
|
||||
publish:publishImport,prepare:(line,job,reserve)=>prepareNativeImport(line,job,reserve,editor.getSnapshot().document,options.registry),persist,onError:report,
|
||||
publish:publishImport,prepare:(line,job,reserve)=>job.source?.kind==='duplicate-ndjson'?prepareDuplicateImport(line,job,reserve):prepareNativeImport(line,job,reserve,editor.getSnapshot().document,options.registry),persist,onError:report,
|
||||
commit(units,next,expected){
|
||||
assertWritable();if(importing)throw fault('reentrant_dispatch');const current=getImport(id);if(current.generation!==expected)throw fault('stale_import_job');
|
||||
try{assertNativeImportTarget(current,editor.getSnapshot().document);for(const unit of units)assertNativeImportDependencies(current,editor.getSnapshot().document,unit.dependencies);}catch(error){return reject(error);}
|
||||
try{assertNativeImportTarget(current,editor.getSnapshot().document);for(const unit of units){assertNativeImportDependencies(current,editor.getSnapshot().document,unit.dependencies);if(current.source?.kind==='duplicate-ndjson')assertDuplicateExternal({cursor:0,nextCursor:1,key:unit.key,value:unit.guard},current,editor.getSnapshot().document);}}catch(error){return reject(error);}
|
||||
importing={job:next,expectedGeneration:expected};
|
||||
try{return editor.transaction(tx=>{for(const unit of units)for(const change of unit.changes){if(change.kind==='element'&&change.before===null&&change.after)tx.dispatch({type:'element.create',element:change.after});else if(change.kind==='canvas'&&change.before===null&&change.after)tx.dispatch({type:'canvas.create',canvas:change.after});else throw fault('invalid_native_import_unit');}},{label:'Import canvas content',groupId:`import:${id}`});}finally{importing=undefined;}
|
||||
try{return editor.transaction(tx=>{for(const unit of units)for(const change of unit.changes){if(change.kind==='element'&&change.before===null&&change.after)tx.dispatch({type:'element.create',element:change.after});else if(change.kind==='canvas'&&change.before===null&&change.after)tx.dispatch({type:'canvas.create',canvas:change.after});else throw fault('invalid_native_import_unit');}},{label:current.source?.kind==='duplicate-ndjson'?'Duplicate selection':'Import canvas content',groupId:`import:${id}`});}finally{importing=undefined;}
|
||||
},
|
||||
});
|
||||
importRunners.set(id,runner);for(const releases of importHolds.values())releases.set(id,runner.hold());
|
||||
@@ -343,7 +346,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const imports=Object.freeze({
|
||||
getJobs:()=>readMap(editor.getSnapshot().runtime.imports??new Map()),
|
||||
startNdjson(bytes:Blob,targetCanvasId:Id):Id{
|
||||
assertWritable();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');
|
||||
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 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});
|
||||
@@ -351,6 +354,26 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
|
||||
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;
|
||||
},
|
||||
async duplicate(selection:readonly Target[],settings:{readonly worldOffset?:Point;readonly signal?:AbortSignal;readonly viewCanvasId?:Id}={}):Promise<Id>{
|
||||
assertWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const captured=editor.getSnapshot(),target=settings.viewCanvasId??(selection[0]?captured.document.elements.get(selection[0].id)?.ownerCanvasId:undefined),signal=settings.signal,controller=new AbortController(),abort=()=>controller.abort();
|
||||
signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)controller.abort();
|
||||
const task=(async()=>{
|
||||
const plan=await planDuplicateAsync(captured.document,options.registry,selection,{createId,worldOffset:settings.worldOffset},{signal:controller.signal});
|
||||
const bytes=await encodeDuplicateSource(captured.document,options.registry,plan,controller.signal);
|
||||
assertWritable();if(controller.signal.aborted)throw fault('copy_aborted');const permission=options.policy?.({type:'changes.apply',changes:plan.changes},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
|
||||
// Task belongs to the initiating view; copied records retain their mapped document owners.
|
||||
if(!target)throw fault('empty_copy_selection');
|
||||
const canvas=editor.getSnapshot().document.canvases.get(target);if(!canvas)throw fault('copy_external_changed',[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');
|
||||
const entry=canvas.parent?editor.getSnapshot().document.elements.get(canvas.parent.entryElementId)!:undefined;
|
||||
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 job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'duplicate-ndjson',name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:plan.roots},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),stableIds,status:'running' as const});
|
||||
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)});});
|
||||
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;
|
||||
})();
|
||||
copyPreparations.set(controller,task);void task.finally(()=>{copyPreparations.delete(controller);signal?.removeEventListener('abort',abort);}).catch(()=>{});return task;
|
||||
},
|
||||
async pause(id:Id){assertWritable();const runner=importRunners.get(id);if(runner)await runner.pause();else{const job=getImport(id);if(job.status==='running'){publishImport(freeze({...job,generation:job.generation+1,status:'paused'}),job.generation);await persist();}}},
|
||||
async resume(id:Id){assertWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(job.status==='running'){await loadImport(id);return;}await importRunners.get(id)?.settle();await persist();await scheduler.resume();assertWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:'running' as const};delete next.error;publishImport(freeze(next),current.generation);await loadImport(id);},
|
||||
async cancel(id:Id){assertWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','paused'].includes(job.status)){const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(freeze(next),job.generation);await persist();}}},
|
||||
@@ -520,10 +543,10 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
cancelRecovery:async()=>{await recovery?.cancel();},
|
||||
getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }),
|
||||
retry: async () => { if(recovery)await recovery.cancel();assertWritable();await scheduler.resume();await runTracker.retry(); },
|
||||
async settle() { await Promise.resolve();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 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; sessions.dispose(); scheduler.stop(); runTracker.stop();
|
||||
closing = true;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()]);
|
||||
recovery?.controller.abort();recovery?.release();
|
||||
for (const release of [...displays]) release();
|
||||
|
||||
Reference in New Issue
Block a user