feat(canvas): schedule deep copy planning cooperatively

This commit is contained in:
2026-11-08 15:47:57 +08:00
parent ce02e43958
commit 379db2fe14
5 changed files with 178 additions and 21 deletions
+8 -2
View File
@@ -34,12 +34,14 @@ export function commonCanvas(document: Document, left: Id, right: Id): Id {
if (!result) throw fault('no_common_canvas', [left, right]);
return result;
}
export function validateDocument(document: Document, registry: Registry): void {
/** Cooperative validation over an immutable document; synchronous callers drain the same checks. */
export function* validateDocumentSteps(document: Document, registry: Registry): Generator<void, void> {
if (!document.canvases.size) throw fault('missing_root');
for (const [id, incarnation] of document.tombstones) if (!id || !Number.isSafeInteger(incarnation) || incarnation < 1) throw fault('invalid_tombstone', [id]);
for (const [id, incarnation] of document.tombstones) { yield; if (!id || !Number.isSafeInteger(incarnation) || incarnation < 1) throw fault('invalid_tombstone', [id]); }
const entries = new Set<Id>();
let roots = 0;
for (const canvas of document.canvases.values()) {
yield;
if (typeof canvas.id !== 'string' || !canvas.id || !Object.hasOwn(canvas,'parent') || canvas.parent !== null && (!canvas.parent || typeof canvas.parent.canvasId !== 'string' || typeof canvas.parent.entryElementId !== 'string')) throw fault('invalid_canvas');
ancestors(document, canvas.id);
if (!canvas.parent) { roots++; continue; }
@@ -49,6 +51,7 @@ export function validateDocument(document: Document, registry: Registry): void {
}
if (roots !== 1) throw fault('invalid_roots');
for (const element of document.elements.values()) {
yield;
if (typeof element.id !== 'string' || !element.id || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !document.canvases.has(element.ownerCanvasId) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data)) throw fault('invalid_element', [element.id]);
const dead = document.tombstones.get(element.id);
if (dead !== undefined && element.incarnation <= dead) throw fault('stale_incarnation', [element.id]);
@@ -64,6 +67,9 @@ export function validateDocument(document: Document, registry: Registry): void {
}
}
}
export function validateDocument(document: Document, registry: Registry): void {
for (const _ of validateDocumentSteps(document, registry)) { /* Drain identical validation without scheduling. */ }
}
export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
for (const change of changes) {
+54 -19
View File
@@ -1,5 +1,5 @@
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
import { ancestors, applyChanges, commonCanvas, targetElement, validateDocument } from './document.ts';
import { ancestors, commonCanvas, targetElement, validateDocumentSteps } from './document.ts';
import { connectionPathValid } from './features.ts';
import { canvasTransform, convertPath, toLocal, toWorld } from './coordinates.ts';
import { clone, fault, freeze } from './values.ts';
@@ -34,45 +34,51 @@ export function remapCopyData(element: ElementRecord, registry: Registry, elemen
}
/** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */
export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan {
validateDocument(document, registry);
export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
yield* validateDocumentSteps(document, registry);
const offset = options.worldOffset ?? { x: 32, y: 32 };
if (![offset.x, offset.y].every(Number.isFinite)) throw fault('invalid_copy_offset');
const selected = new Set(selection.map(target => targetElement(document, target).id));
const selected = new Set<Id>();
for (const target of selection) { yield; selected.add(targetElement(document, target).id); }
if (!selected.size) throw fault('empty_copy_selection');
const childByEntry = new Map<Id, CanvasRecord>(), members = new Map<Id, ElementRecord[]>();
for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas);
for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
const roots = [...selected].filter(id => !ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); }));
for (const canvas of document.canvases.values()) { yield; if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas); }
for (const element of document.elements.values()) { yield; const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
const roots: Id[] = [];
for (const id of selected) { yield; if (!ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); })) roots.push(id); }
const copied = new Set<Id>(), copiedCanvases = new Set<Id>(), order: ({ kind: 'element'; record: ElementRecord } | { kind: 'canvas'; record: CanvasRecord })[] = [];
const stack = roots.filter(id => document.elements.get(id)!.structure === 'node').reverse().map(id => ({ kind: 'element' as const, record: document.elements.get(id)! }));
const work: typeof order = stack;
const work: typeof order = [];
for (let index = roots.length - 1; index >= 0; index--) { yield; const record = document.elements.get(roots[index])!; if (record.structure === 'node') work.push({kind:'element',record}); }
while (work.length) {
yield;
const item = work.pop()!;
if (item.kind === 'canvas') {
if (copiedCanvases.has(item.record.id)) continue;
copiedCanvases.add(item.record.id); order.push(item);
for (const member of [...(members.get(item.record.id) ?? [])].reverse()) if (member.structure === 'node') work.push({ kind: 'element', record: member });
const list = members.get(item.record.id) ?? [];
for (let index = list.length - 1; index >= 0; index--) { yield; const member = list[index]; if (member.structure === 'node') work.push({ kind: 'element', record: member }); }
} else {
if (copied.has(item.record.id)) continue;
copied.add(item.record.id); order.push(item);
const child = childByEntry.get(item.record.id); if (child) work.push({ kind: 'canvas', record: child });
}
}
const edges = [...document.elements.values()].filter(element => element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId)));
for (const edge of edges) copied.add(edge.id);
const elementIds = new Map<Id, Id>(), canvasIds = new Map<Id, Id>(), used = new Set([...document.elements.keys(), ...document.canvases.keys(), ...document.tombstones.keys()]);
const edges: ElementRecord[] = [];
for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } }
const elementIds = new Map<Id, Id>(), canvasIds = new Map<Id, Id>(), used = new Set<Id>();
for (const map of [document.elements, document.canvases, document.tombstones]) for (const id of map.keys()) { yield; used.add(id); }
const allocate = (source: Id, kind: 'elements' | 'canvases') => {
const supplied = options.mapping?.[kind];
const id = supplied && Object.hasOwn(supplied, source) ? supplied[source] : options.createId();
if (typeof id !== 'string' || !id || used.has(id)) throw fault('duplicate_identity', [id]);
used.add(id); return id;
};
for (const id of copied) elementIds.set(id, allocate(id, 'elements'));
for (const id of copiedCanvases) canvasIds.set(id, allocate(id, 'canvases'));
if (options.mapping && (Object.keys(options.mapping.elements).some(id => !copied.has(id)) || Object.keys(options.mapping.canvases).some(id => !copiedCanvases.has(id)))) throw fault('copy_mapping_scope_mismatch');
for (const id of copied) { yield; elementIds.set(id, allocate(id, 'elements')); }
for (const id of copiedCanvases) { yield; canvasIds.set(id, allocate(id, 'canvases')); }
if (options.mapping) for (const [mapping, scope] of [[options.mapping.elements,copied],[options.mapping.canvases,copiedCanvases]] as const) for (const id in mapping) { yield; if (Object.hasOwn(mapping,id) && !scope.has(id)) throw fault('copy_mapping_scope_mismatch'); }
const changes: Change[] = [];
for (const item of order) {
yield;
if (item.kind === 'canvas') {
const parent = item.record.parent!;
const after = { ...clone(item.record), id: canvasIds.get(item.record.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
@@ -88,8 +94,11 @@ export function planDuplicate(document: Document, registry: Registry, selection:
changes.push({ kind: 'element', id: after.id, before: null, after });
}
}
const nodes = applyChanges(document, changes);
const nodeElements = new Map<Id, ElementRecord>(), nodeCanvases = new Map<Id, CanvasRecord>();
for (const change of changes) { yield; if (change.kind === 'element' && change.after) nodeElements.set(change.id, change.after); else if (change.kind === 'canvas' && change.after) nodeCanvases.set(change.id, change.after); }
const nodes: Document = { ...document, elements: overlay(document.elements, nodeElements), canvases: overlay(document.canvases, nodeCanvases) };
for (const edge of edges) {
yield;
if (edge.structure !== 'connection') continue;
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]);
const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId };
@@ -107,7 +116,33 @@ export function planDuplicate(document: Document, registry: Registry, selection:
}
const after: ElementRecord = { ...clone(edge), id: elementIds.get(edge.id)!, incarnation: 1, ownerCanvasId: owner, source, target, path, data: remapCopyData(edge, registry, elementIds, canvasIds, document) };
changes.push({ kind: 'element', id: after.id, before: null, after });
nodeElements.set(after.id, after);
}
validateDocument(applyChanges(document, changes), registry);
return freeze({ version: 1, roots: roots.map(id => elementIds.get(id)!), elementIds: Object.fromEntries(elementIds), canvasIds: Object.fromEntries(canvasIds), changes });
yield* validateDocumentSteps(nodes, registry);
const elementMapping: Record<Id,Id> = {}, canvasMapping: Record<Id,Id> = {}, copiedRoots: Id[] = [];
for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); }
for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); }
for (const id of roots) { yield; copiedRoots.push(elementIds.get(id)!); }
for (const change of changes) { yield; freeze(change); }
return Object.freeze({ version: 1, roots: Object.freeze(copiedRoots), elementIds: Object.freeze(elementMapping), canvasIds: Object.freeze(canvasMapping), changes: Object.freeze(changes) });
}
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
const view: ReadonlyMap<K,V> = {
get size() { return base.size + additions.size; },
get: key => additions.has(key) ? additions.get(key) : base.get(key), has: key => additions.has(key) || base.has(key),
*entries(): Generator<[K,V],undefined> { yield* base; yield* additions; return undefined; },
*keys(): Generator<K,undefined> { yield* base.keys(); yield* additions.keys(); return undefined; },
*values(): Generator<V,undefined> { yield* base.values(); yield* additions.values(); return undefined; },
[Symbol.iterator]() { return this.entries(); },
forEach(callback,thisArg) { for (const [key,value] of view) callback.call(thisArg,value,key,view); },
};
return Object.freeze(view);
}
/** Synchronous API retained for small snapshots and callers that own scheduling. */
export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan {
const steps = planDuplicateSteps(document,registry,selection,options);
while (true) { const step = steps.next(); if (step.done) return step.value; }
}
+44
View File
@@ -0,0 +1,44 @@
import type { Document, Registry, Target } from '../canvas-core/types.ts';
import { planDuplicateSteps, type DuplicateOptions, type DuplicatePlan } from '../canvas-core/duplicate.ts';
import { clone, fault, freeze } from '../canvas-core/values.ts';
export type DuplicateScheduling = {
readonly signal?: AbortSignal;
readonly frameBudgetMs?: number;
readonly maxStepsPerSlice?: number;
readonly now?: () => number;
readonly yieldTask?: () => Promise<void>;
};
/**
* Cooperative planning from a captured immutable StateRoot document, not a live editor getter.
* No document publication or network effects. The caller must persist source/mapping and
* revalidate external identities before applying a plan; this is not the import executor.
*/
export async function planDuplicateAsync(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions, scheduling: DuplicateScheduling = {}): Promise<DuplicatePlan> {
const budget = scheduling.frameBudgetMs ?? 6, limit = scheduling.maxStepsPerSlice ?? 512;
if (!Number.isFinite(budget) || budget <= 0 || !Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_copy_budget');
const signal = scheduling.signal, now = scheduling.now ?? (() => performance.now()), yieldTask = scheduling.yieldTask ?? (() => new Promise<void>(resolve => setTimeout(resolve, 0)));
const check = () => { if (signal?.aborted) throw fault('copy_aborted'); };
check();
// Capture caller-owned small inputs before the first await. Document/registry are immutable core snapshots.
const targets = freeze(clone(selection)), settings: DuplicateOptions = Object.freeze({ createId: options.createId, ...(options.worldOffset ? {worldOffset: freeze(clone(options.worldOffset))} : {}), ...(options.mapping ? {mapping: freeze(clone(options.mapping))} : {}) });
const steps = planDuplicateSteps(document, registry, targets, settings);
try {
while (true) {
check();
const start = now(); let count = 0;
do {
check();
const step = steps.next();
check();
if (step.done) return step.value;
count++;
} while (count < limit && now() - start < budget);
await yieldTask();
}
} finally {
// Release snapshot references after cancellation or host scheduling failures.
steps.return(undefined as never);
}
}