Resolve default image sizes atomically with creation history and recovery

This commit is contained in:
2026-11-09 00:25:18 +08:00
parent fa7dad44e7
commit 5919eb0453
20 changed files with 456 additions and 40 deletions
+22 -3
View File
@@ -8,6 +8,8 @@ import { unitScale, withUnitScale } from './units.ts';
import { removalChanges, repairConnectionOwners } from './structure.ts';
import { planHistoryChanges } from './history.ts';
import { describeHistoryTransition } from './history-transition.ts';
import { planCreationSize, validateCreationSizeEffect } from './creation-size-plan.ts';
import type { CreationSizeEffect } from './types.ts';
export type EditorOptions<Runtime = undefined> = {
registry: Registry; document: Document; queryBudget?:QueryBudget; createId?: () => Id; historyLimit?: number;
@@ -135,7 +137,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (!id || used.has(id) || state.operations.has(id) || state.history.entries.has(id)) throw fault('duplicate_operation_id');
used.add(id); return id;
}
function publish(document: Document, changes: readonly Change[], origin: Origin, history: HistoryState, historyEntryId?: Id): Applied {
function publish(document: Document, changes: readonly Change[], origin: Origin, history: HistoryState, historyEntryId?: Id, historyEffect?: CreationSizeEffect): Applied {
const used = new Set<Id>();
for (const id of history.entries.keys()) used.add(id);
// Canonical remote/ACK/replay updates must never feed back into the outbox.
@@ -180,9 +182,9 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
}
}
const historyTransition=describeHistoryTransition(state.history,history,state.rootRevision+1,historyEntryId,true);
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [],...(historyTransition?{historyTransition}:{}) });
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [],...(historyTransition?{historyTransition}:{}),...(historyEffect?{historyEffect}:{}) });
const prepared=options.prepareIntent?.(intent,state.runtime) ?? intent;
if(prepared.operationType==='run.apply'&&!validJson(prepared.action)||!['document.change','run.apply'].includes(prepared.operationType)||['id','operationSchemaVersion','sequence','origin','canvasIds','changes','dependencies','historyTransition'].some(key=>!equal((prepared as unknown as Record<string,unknown>)[key],(intent as unknown as Record<string,unknown>)[key])))throw fault('invalid_prepared_intent');
if(prepared.operationType==='run.apply'&&!validJson(prepared.action)||!['document.change','run.apply'].includes(prepared.operationType)||['id','operationSchemaVersion','sequence','origin','canvasIds','changes','dependencies','historyTransition','historyEffect'].some(key=>!equal((prepared as unknown as Record<string,unknown>)[key],(intent as unknown as Record<string,unknown>)[key])))throw fault('invalid_prepared_intent');
operations.set(intent.id, freeze(clone(prepared)));
}
const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) });
@@ -268,6 +270,23 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
finally { entered = false; }
}
return Object.freeze({ getSnapshot: () => state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can,
/** Coordinator entry: document, amended history and outbound fact share one Root. */
resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
try {
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
validateCreationSizeEffect(effect);
const plan = planCreationSize(state.document, state.history, options.registry, effect.entryId, effect.target, effect.size);
const after = plan.document.elements.get(effect.target.id)!;
if (after.structure !== 'node') throw fault('creation_size_target_changed');
const allowed = options.policy?.({ type: 'element.geometry', target: effect.target, patch: { width: after.geometry.width, height: after.geometry.height } }, state.document);
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
if (!plan.changes.length) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
return publish(plan.document, plan.changes, 'asset-resolution', plan.history, effect.entryId, freeze(clone(effect)));
} catch (error) { return reject(error); }
finally { entered = false; }
},
subscribe(listener: (commit: Commit, snapshot: Snapshot<Runtime>) => void) { if (disposed) throw fault('editor_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; },
/** Engine-only metadata commit, sharing the document/history publication boundary. */
updateRuntime(reduce: (runtime: Runtime) => Runtime, expectedRootRevision = state.rootRevision): Result {