feat(canvas): persist and execute deep duplicate tasks

This commit is contained in:
2026-11-08 15:59:15 +08:00
parent 379db2fe14
commit 7ed0aa61cb
8 changed files with 188 additions and 18 deletions
@@ -0,0 +1,63 @@
import type { CanvasRecord, Change, Document, Id, Json, Registry } from '../canvas-core/types.ts';
import type { DuplicatePlan } from '../canvas-core/duplicate.ts';
import { clone, equal, fault, validJson } from '../canvas-core/values.ts';
import type { ImportJob, ImportLine, ImportUnit } from './imports.ts';
type ExternalElement = {id: Id; incarnation: number; ownerCanvasId: Id};
type ExternalCanvas = {id: Id; parent: CanvasRecord['parent']; entryIncarnation?: number};
type CopyLine = {key: string; change: Exclude<Change,{kind:'field'}>; dependencies: string[]; externalElements: ExternalElement[]; externalCanvases: ExternalCanvas[]};
/** Internal source: already mapped records, with explicit guards for every retained external reference. */
export async function encodeDuplicateSource(document: Document, registry: Registry, plan: DuplicatePlan, signal: AbortSignal): Promise<Blob> {
const keys = new Map<Id,string>(),parts: BlobPart[] = [];
for (const [id,mapped] of Object.entries(plan.elementIds)) keys.set(mapped,`element:${id}`);
for (const [id,mapped] of Object.entries(plan.canvasIds)) keys.set(mapped,`canvas:${id}`);
let bytes=0,count=0;
for (const change of plan.changes) {
if(signal.aborted)throw fault('copy_aborted');
if(change.kind==='field'||!change.after||change.before!==null)throw fault('invalid_duplicate_plan');
const dependencies=new Set<string>(),elements=new Map<Id,ExternalElement>(),canvases=new Map<Id,ExternalCanvas>();
const canvas=(id:Id)=>{
const key=keys.get(id);if(key){dependencies.add(key);return;}
let next:Id|undefined=id;
while(next&&!canvases.has(next)){
const original=document.canvases.get(next);if(!original)throw fault('copy_external_changed',[next]);
const entry=original.parent?document.elements.get(original.parent.entryElementId):undefined;
canvases.set(next,{id:next,parent:clone(original.parent),...(entry?{entryIncarnation:entry.incarnation}:{})});
if(entry)elements.set(entry.id,{id:entry.id,incarnation:entry.incarnation,ownerCanvasId:entry.ownerCanvasId});
next=original.parent?.canvasId;
}
};
const element=(id:Id)=>{const key=keys.get(id);if(key){dependencies.add(key);return;}const original=document.elements.get(id);if(!original)throw fault('copy_external_changed',[id]);elements.set(id,{id,incarnation:original.incarnation,ownerCanvasId:original.ownerCanvasId});canvas(original.ownerCanvasId);};
if(change.kind==='canvas'){if(!change.after.parent)throw fault('invalid_duplicate_plan');canvas(change.after.parent.canvasId);element(change.after.parent.entryElementId);}
else {
const record=change.after;canvas(record.ownerCanvasId);
if(record.structure==='connection'){element(record.source.elementId);element(record.target.elementId);}
const schema=registry.components.get(record.componentId)?.copy;if(!schema)throw fault('copy_schema_unavailable');
for(const ref of schema.references){let value:Json|undefined=record.data;for(const part of ref.path){if(!value||typeof value!=='object'||!Object.hasOwn(value,part)){value=undefined;break;}value=(value as Record<string,Json>)[part];}if(value===undefined||value===null)continue;for(const id of ref.many?value as Json[]:[value]){if(typeof id!=='string')throw fault('invalid_copy_reference');if(ref.target==='element')element(id);else canvas(id);}}
}
const key=keys.get(change.id);if(!key)throw fault('invalid_duplicate_plan');dependencies.delete(key);
const line:CopyLine={key,change,dependencies:[...dependencies],externalElements:[...elements.values()],externalCanvases:[...canvases.values()]};
const text=JSON.stringify(line)+'\n',size=new TextEncoder().encode(text).byteLength;if(size>256*1024)throw fault('record_too_large');bytes+=size;if(bytes>64*1024*1024)throw fault('invalid_import_source');parts.push(text);
if(++count%32===0)await new Promise<void>(resolve=>setTimeout(resolve,0));
}
if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'});
}
function copyLine(line:ImportLine,job:ImportJob):CopyLine {
const value=line.value as unknown as CopyLine;
if(job.source?.kind!=='duplicate-ndjson'||!value||!validJson(value)||typeof value.key!=='string'||!Array.isArray(value.dependencies)||!Array.isArray(value.externalElements)||!Array.isArray(value.externalCanvases)||!value.change||value.change.kind!=='element'&&value.change.kind!=='canvas'||value.change.before!==null||!value.change.after||!Object.hasOwn(job.stableIds,value.key)||job.stableIds[value.key]!==value.change.id||value.change.after.id!==value.change.id||!value.key.startsWith(`${value.change.kind}:`))throw fault('invalid_duplicate_source');
if(value.change.kind==='element'&&value.change.after?.incarnation!==1)throw fault('invalid_duplicate_source');
return value;
}
export function prepareDuplicateImport(line:ImportLine,job:ImportJob,reserve:(key:string)=>Id):ImportUnit {
const value=copyLine(line,job);if(reserve(value.key)!==value.change.id)throw fault('invalid_duplicate_source');
for(const key of value.dependencies)if(!Object.hasOwn(job.stableIds,key))throw fault('invalid_duplicate_source');
return{key:value.key,dependencies:value.dependencies,changes:[value.change],guard:clone(line.value!)};
}
/** Check at the synchronous commit boundary, never only when the source was first parsed. */
export function assertDuplicateExternal(line:ImportLine,job:ImportJob,document:Document){
const value=copyLine(line,job);
for(const guard of value.externalElements){const record=document.elements.get(guard.id);if(!record||record.incarnation!==guard.incarnation||record.ownerCanvasId!==guard.ownerCanvasId)throw fault('copy_external_changed',[guard.id]);}
for(const guard of value.externalCanvases){const record=document.canvases.get(guard.id);if(!record||!equal(record.parent,guard.parent)||guard.parent&&document.elements.get(guard.parent.entryElementId)?.incarnation!==guard.entryIncarnation)throw fault('copy_external_changed',[guard.id]);}
}