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
@@ -254,7 +254,7 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
<IconButton icon={Sparkles} label="AI 创作" disabled={!aiElement} aria-pressed={showAI} onClick={() => setShowAI(value => !value)} />
<IconButton icon={Type} label="添加纯文本" onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" onClick={() => add('frame')} />
</div>
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
<HeadlessImports engine={engine} canvasId={view.canvasId} />
{showAI && aiElement && <HeadlessAIPanel key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
{showRecovery && <HeadlessRecoveryDialog engine={engine} close={() => setShowRecovery(false)} />}
+10 -2
View File
@@ -1,7 +1,15 @@
import type { Change, Document, ElementRecord, Geometry, HistoryState, Id, Registry, Target } from './types.ts';
import type { Change, CreationSizeEffect, Document, ElementRecord, Geometry, HistoryState, Id, Registry, Target } from './types.ts';
import { applyChanges, invert, validateDocument } from './document.ts';
import { closeNumber, unitScale } from './units.ts';
import { equal, fault, freeze, readMap } from './values.ts';
import { equal, fault, freeze, readMap, validJson } from './values.ts';
export function validateCreationSizeEffect(value: unknown): asserts value is CreationSizeEffect {
const effect = value as CreationSizeEffect | undefined;
if (!effect || effect.version !== 1 || effect.kind !== 'creation-size') throw fault('history_effect_interpreter_unavailable');
if (!validJson(effect) || typeof effect.entryId !== 'string' || !effect.entryId || typeof effect.target?.id !== 'string' || !effect.target.id
|| !Number.isSafeInteger(effect.target.incarnation) || effect.target.incarnation < 1
|| !effect.size || ![effect.size.width, effect.size.height].every(value => typeof value === 'number' && Number.isFinite(value) && value > 0)) throw fault('invalid_creation_size_effect');
}
/** A pure proposal, not a publication API. The coordinator must still validate a
* durable sizing grant and record a recoverable history effect before adopting it.
+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 {
+2 -1
View File
@@ -36,7 +36,8 @@ export type Applied = { readonly status: 'applied'; readonly revision: number; r
export type Result = Applied | Rejected;
export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id };
export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> };
type IntentBase = { readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } };
type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean };
export type RunCancelInput = { readonly runId: Id; readonly boardId: Id };
export type RunApplyInput = RunCancelInput & { readonly targetCanvasId: Id; readonly target: Target; readonly outputId: Id };
+1 -1
View File
@@ -46,7 +46,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
for(const record of records){
const intent=record.intent,choice=choices.get(intent.id)!;
if(intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[intent.id]);
if(intent.changes.length&&!['user','undo','redo'].includes(intent.origin))throw fault('recovery_history_traversal_not_supported',[intent.id]);
if(intent.changes.length&&!['user','undo','redo'].includes(intent.origin)&&!(intent.origin==='asset-resolution'&&intent.historyEffect))throw fault('recovery_history_traversal_not_supported',[intent.id]);
const before=document,entryId=historyOwner.get(intent.id);
if(entryId&&!boundaries.has(entryId))boundaries.set(entryId,{before,after:before});
for(const original of intent.changes)if(original.kind==='field'){
@@ -0,0 +1,84 @@
import type { Commit, CoreSnapshot, Id, Registry, Target } from '../canvas-core/types.ts';
import { closeNumber, unitScale } from '../canvas-core/units.ts';
import { fault, freeze, readMap } from '../canvas-core/values.ts';
import type { EngineSnapshot, RuntimeState } from './contracts.ts';
import type { ResourceRecord } from './resources.ts';
export type DefaultSizeGrant = {
readonly target: Target; readonly entryId: Id; readonly componentId: string; readonly dataVersion: number;
readonly initialSize: { readonly width: number; readonly height: number };
readonly status: 'pending' | 'applied' | 'revoked' | 'blocked'; readonly code?: string;
};
export type DefaultImageSizing = { readonly version: 1; readonly grants: readonly DefaultSizeGrant[] };
export function validateDefaultImageSizing(value: unknown): asserts value is DefaultImageSizing {
const state = value as DefaultImageSizing;
if (!state || state.version !== 1) throw fault('default_size_interpreter_unavailable');
if (!Array.isArray(state.grants)) throw fault('invalid_default_size_grant');
const ids = new Set<string>();
for (const grant of state.grants) {
if (!grant || typeof grant.target?.id !== 'string' || !grant.target.id || !Number.isSafeInteger(grant.target.incarnation) || grant.target.incarnation < 1
|| typeof grant.entryId !== 'string' || !grant.entryId || typeof grant.componentId !== 'string' || !grant.componentId || !Number.isSafeInteger(grant.dataVersion) || grant.dataVersion < 1
|| !grant.initialSize || ![grant.initialSize.width, grant.initialSize.height].every(value => typeof value === 'number' && Number.isFinite(value) && value > 0)
|| !['pending', 'applied', 'revoked', 'blocked'].includes(grant.status) || grant.code !== undefined && (typeof grant.code !== 'string' || !grant.code)) throw fault('invalid_default_size_grant');
const key = JSON.stringify([grant.target.id, grant.target.incarnation]);
if (ids.has(key)) throw fault('invalid_default_size_grant'); ids.add(key);
}
}
export function captureDefaultImageSizing(resource: ResourceRecord, next: CoreSnapshot, commit: Commit, registry: Registry): ResourceRecord {
const grants: DefaultSizeGrant[] = [];
for (const entry of next.history.entries.values()) {
if (!entry.operationIds.some(id => commit.operationIds.includes(id))) continue;
for (const change of commit.changes) {
if (change.kind !== 'element' || change.before || change.after?.structure !== 'node') continue;
const element = next.document.elements.get(change.id);
if (element?.structure !== 'node' || element.incarnation !== change.after.incarnation || element.data.assetId !== resource.id || registry.resolve(element) !== 'known'
|| !registry.components.get(element.componentId)?.resourceFields?.includes('assetId') || !registry.supports(element, 'geometry.resize')) continue;
const units = unitScale(element.data.presentation);
grants.push(freeze({ target: { id: element.id, incarnation: element.incarnation }, entryId: entry.id, componentId: element.componentId, dataVersion: element.dataVersion,
initialSize: { width: element.geometry.width / units, height: element.geometry.height / units }, status: 'pending' }));
}
}
return freeze({ ...resource, sizing: { version: 1, grants } });
}
/** Runs on every publication path. Revocation is durable and does not revive when
* undo happens to return to the default geometry. Preview-only gestures do not publish.
*/
export function prepareDefaultImageSizing(previous: EngineSnapshot, next: EngineSnapshot): RuntimeState {
let resources = next.runtime.resources;
for (const [id, resource] of resources) {
if (resource.sizing?.version !== 1) continue;
let changed = false;
const grants = resource.sizing.grants.map(grant => {
if (!['pending', 'blocked'].includes(grant.status)) return grant;
const element = next.document.elements.get(grant.target.id);
let code: string | undefined;
if (element?.structure !== 'node' || element.incarnation !== grant.target.incarnation || element.componentId !== grant.componentId || element.dataVersion !== grant.dataVersion || element.data.assetId !== id) code = 'target_changed';
else if (!next.history.undo.includes(grant.entryId)) code = 'creation_history_unavailable';
else {
const units = unitScale(element.data.presentation);
if (!closeNumber(element.geometry.width / units, grant.initialSize.width) || !closeNumber(element.geometry.height / units, grant.initialSize.height)) code = 'user_resized';
// Detect committed resize-away-and-back transactions. Pure coordinate
// conversions may alter geometry sizes and unitScale together.
if (!code && previous.document !== next.document && previous.operations.size !== next.operations.size) {
for (const [operationId, intent] of next.operations) {
if (previous.operations.has(operationId) || intent.origin !== 'user') continue;
const conversion = intent.changes.some(change => change.kind === 'field' && change.target.id === grant.target.id
&& (change.field === 'ownerCanvasId' || change.field === 'data.presentation' && !closeNumber(unitScale(change.before), unitScale(change.after))));
if (!conversion && intent.changes.some(change => change.kind === 'field' && change.target.id === grant.target.id && change.field === 'geometry'
&& (!closeNumber((change.before as { width: number }).width, (change.after as { width: number }).width) || !closeNumber((change.before as { height: number }).height, (change.after as { height: number }).height)))) code = 'user_resized';
}
}
}
if (!code) return grant;
changed = true; return freeze({ ...grant, status: 'revoked' as const, code });
});
if (changed) {
if (resources === next.runtime.resources) resources = new Map(resources);
(resources as Map<Id, ResourceRecord>).set(id, freeze({ ...resource, sizing: { version: 1, grants } }));
}
}
return resources === next.runtime.resources ? next.runtime : Object.freeze({ ...next.runtime, resources: readMap(resources) });
}
+71 -10
View File
@@ -1,5 +1,7 @@
import {validImageDimensions,type ImageDecoderPort} from './image-decoder.ts';
import {unitScale} from '../canvas-core/units.ts';
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
import { captureDefaultImageSizing, prepareDefaultImageSizing } from './default-image-size.ts';
import {orphanCoverage,prepareOrphanFiles,abortableFileRead,type OrphanFile} from './orphan-files.ts';
import {importFiles,terminalImport,markImportFiles,prepareImportFileRetention,type FileRetirement} from './import-file-retention.ts';
import {resourceRetentionPins,prepareResourceRetention} from './resource-retention.ts';
@@ -75,8 +77,10 @@ export async function openProjectEngine(options: EngineOptions) {
for(const [id,entry]of importFiles(recovered?.runtime.imports))if(entry.file.retirement?.purgedAt!==undefined)durablyPurged.add(id);
let collection:Promise<CollectionReport>|undefined;
const imageTasks=new Map<Id,Promise<void>>();
let sizingTask: Promise<void> | undefined;
const fileWrites = new Map<Id, Blob>(), digestTasks = new Map<Id, Promise<void>>();
let staged: readonly ResourceRecord[] = [];
const stagedDefaultSizes = new Set<Id>();
let applying: RunApplyInput | undefined;
let importing: { job: ImportJob; expectedGeneration: number } | undefined;
const preparationControllers=new Map<Id,AbortController>();
@@ -91,7 +95,8 @@ export async function openProjectEngine(options: EngineOptions) {
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)=>{
const runtime=prepareResourceRetention(next,options.registry,displayPins,collectionReservations,admissionPins(next));
const sizing=prepareDefaultImageSizing(previous,next);
const runtime=prepareResourceRetention({...next,runtime:sizing},options.registry,displayPins,collectionReservations,admissionPins(next));
const changed=previous.document!==next.document||previous.history!==next.history||previous.runtime.ledger!==runtime.ledger||previous.runtime.resourcePins!==runtime.resourcePins;
const imports=prepareImportFileRetention(previous.runtime,runtime,()=>resourceRetentionPins({...next,runtime},options.registry,displayPins,admissionPins({...next,runtime})),collectionReservations,changed);
return prepareOrphanFiles(previous.runtime,imports,()=>orphanCoverage({...next,runtime:imports},options.registry,displayPins,admissionPins({...next,runtime:imports})),collectionReservations,changed);
@@ -104,12 +109,20 @@ export async function openProjectEngine(options: EngineOptions) {
const firstSequence = next.operations.get(commit.operationIds[0])!.sequence - staged.length;
staged.forEach((resource, index) => {
if (resources.has(resource.id) || ledger.getSnapshot().operations.has(resource.operationId)) throw fault('resource_identity_conflict', [resource.id]);
resources.set(resource.id, resource);
resources.set(resource.id, stagedDefaultSizes.has(resource.id) ? captureDefaultImageSizing(resource, next, commit, options.registry) : resource);
ledger.add(freeze({ id: resource.operationId, operationType: 'asset.upload', operationSchemaVersion: 1, sequence: firstSequence + index, origin: 'user', canvasIds: [], changes: [], dependencies: [], resourceId: resource.id }), {});
ledger.block(resource.operationId, ['waiting_digest']);
});
for (const id of commit.operationIds) {
const intent = next.operations.get(id)!, footprint = operationFootprint(intent, next.document);
if (intent.historyEffect) {
const effect = intent.historyEffect;
for (const [resourceId, resource] of resources) if (resource.sizing?.version === 1) {
const grants = resource.sizing.grants.map(grant => grant.status === 'pending' && grant.entryId === effect.entryId && grant.target.id === effect.target.id && grant.target.incarnation === effect.target.incarnation
? freeze({ ...grant, status: 'applied' as const, code: undefined }) : grant);
if (grants.some((grant, index) => grant !== resource.sizing!.grants[index])) resources.set(resourceId, freeze({ ...resource, sizing: { version: 1 as const, grants } }));
}
}
const dependencies = new Set(intent.dependencies);
for (const record of ledger.getSnapshot().operations.values()) if (record.phase !== 'resolved' && (record.intent.operationType === 'run.start' || ['document.change','run.apply'].includes(record.intent.operationType) && conflictsWith(operationFootprint(record.intent, next.document), footprint))) dependencies.add(record.intent.id);
for (const id of referencedResources(intent.changes, options.registry)) {
@@ -371,7 +384,7 @@ export async function openProjectEngine(options: EngineOptions) {
}
const scheduler = createScheduler({ getLedger: () => editor.getSnapshot().runtime.ledger, mutate, persist,
createId: options.createId ?? (() => crypto.randomUUID()),
participants: intent => operationCanvases(intent, editor.getSnapshot().document),
participants: intent => { if(intent.historyEffect)validateCreationSizeEffect(intent.historyEffect);return operationCanvases(intent, editor.getSnapshot().document); },
compile(intent, id) {
if (intent.operationType === 'document.change') return options.persistence.compile(intent, id, editor.getSnapshot());
if (['run.start','run.cancel','run.apply'].includes(intent.operationType)) { if (!options.runs) throw fault('run_interpreter_unavailable');return options.runs.compile(intent,id,editor.getSnapshot()); }
@@ -444,6 +457,7 @@ export async function openProjectEngine(options: EngineOptions) {
poll:(run,signal)=>options.runs ? network.run(signal,()=>options.runs!.poll(run,signal)) : Promise.reject(fault('run_interpreter_unavailable')),onError:report});
const trackedRuns=new Set(initial.runs.keys());
const sessions = createSessions(editor);
const unsubscribeSizing = sessions.subscribe(() => { if (!sessions.getSnapshot().gesture) queueDefaultSizes(); });
const unsubscribe = editor.subscribe((commit, snapshot) => {
pending.set(snapshot.rootRevision, snapshot);
const addedRuns=[...snapshot.runtime.runs.keys()].filter(id=>!trackedRuns.has(id));for(const id of addedRuns)trackedRuns.add(id);
@@ -479,6 +493,7 @@ export async function openProjectEngine(options: EngineOptions) {
}
}
function prepareImages(){
queueDefaultSizes();
if(closing||closed||recovery||!options.images)return;
for(const resource of editor.getSnapshot().runtime.resources.values()){
if(imageTasks.size>=2)break;
@@ -494,6 +509,52 @@ export async function openProjectEngine(options: EngineOptions) {
})().catch(error=>{if(closing||closed||recovery)return;const code=(error as{code?:string}).code??'image_decode_failed';editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.operationId!==resource.operationId||current.image!==resource.image)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'failed' as const,code}}));return Object.freeze({...previous,resources:readMap(resources)});});}).finally(()=>{imageTasks.delete(resource.id);prepareImages();});imageTasks.set(resource.id,task);
}
}
function queueDefaultSizes() {
if (sizingTask || closing || closed || recovery || ![...editor.getSnapshot().runtime.resources.values()].some(resource => resource.sizing?.version === 1 && resource.sizing.grants.some(grant => grant.status === 'pending'))) return;
// A scheduling yield, not a logical retry deadline: avoid a self-extending
// microtask chain when one resource supplies many newly created elements.
sizingTask = new Promise(resolve => { setTimeout(() => { sizingTask = undefined; try { prepareDefaultSizes(); } catch (error) { report(error); } finally { resolve(); } }, 0); });
}
function prepareDefaultSizes() {
if (closing || closed || recovery || sessions.getSnapshot().gesture) return;
const root = editor.getSnapshot();
for (const [id, resource] of root.runtime.resources) {
if (resource.sizing?.version !== 1 || resource.image && resource.image.version !== 1) continue;
const size = resource.image?.version === 1 && resource.image.status === 'ready' ? resource.image : !options.images ? resource.stored : undefined;
if (!size || !validImageDimensions(size)) continue;
for (const grant of resource.sizing.grants) {
if (grant.status !== 'pending') continue;
const element = root.document.elements.get(grant.target.id);
if (element?.structure !== 'node') continue;
const units = unitScale(element.data.presentation), min = units, max = 100000 * units;
const scale = Math.min(element.geometry.width / size.width, max / size.width, max / size.height), grow = Math.max(scale, min / size.width, min / size.height);
const width = size.width * grow, height = size.height * grow;
const result = !Number.isFinite(width) || !Number.isFinite(height) || width > max || height > max ? reject(fault('image_fit_constraint'))
: editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId: grant.entryId, target: grant.target, size: { width: width / units, height: height / units } }, root.rootRevision);
if (result.status === 'rejected' || !result.operationIds.length) {
editor.updateRuntime(previous => {
const current = previous.resources.get(id);
if (current?.sizing?.version !== 1) return previous;
const grants = current.sizing.grants.map(value => value !== grant ? value : freeze({ ...value, status: result.status === 'rejected' ? 'blocked' as const : 'applied' as const, code: result.status === 'rejected' ? result.code : undefined }));
const resources = new Map(previous.resources); resources.set(id, freeze({ ...current, sizing: { version: 1 as const, grants } }));
return Object.freeze({ ...previous, resources: readMap(resources) });
});
if (result.status === 'rejected') report(fault(result.code, result.targets));
}
return; // One publication per pump; its notification schedules further grants.
}
}
}
function retryDefaultSizes() {
editor.updateRuntime(previous => {
const resources = new Map(previous.resources); let changed = false;
for (const [id, resource] of resources) if (resource.sizing?.version === 1 && resource.sizing.grants.some(grant => grant.status === 'blocked')) {
resources.set(id, freeze({ ...resource, sizing: { version: 1 as const, grants: resource.sizing.grants.map(grant => grant.status === 'blocked' ? freeze({ ...grant, status: 'pending' as const, code: undefined }) : grant) } })); changed = true;
}
return changed ? Object.freeze({ ...previous, resources: readMap(resources) }) : previous;
});
queueDefaultSizes();
}
function queueRunAction(operationType:'run.cancel'|'run.apply',action:RunCancelInput|RunApplyInput):Result {
const root=editor.getSnapshot();let sequence=1;for(const record of root.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence+1);
const id=createId(),intent={id,operationType,action,operationSchemaVersion:1,sequence,origin:'user',canvasIds:operationType==='run.apply'?[(action as RunApplyInput).targetCanvasId]:[],changes:[],dependencies:operationType==='run.cancel'?[]:[...root.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)} as OperationIntent;
@@ -757,17 +818,17 @@ export async function openProjectEngine(options: EngineOptions) {
if (staged.length) throw fault('reentrant_dispatch');
if (!options.assets) throw fault('asset_interpreter_unavailable');
const resources = files.map(file => {
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');
if (!(file.bytes instanceof Blob) || !file.bytes.size || file.bytes.size > maxResourceBytes || !file.bytes.type.startsWith('image/') || typeof file.name !== 'string' || !file.name || file.defaultSize !== undefined && typeof file.defaultSize !== 'boolean') throw fault('invalid_staged_file');
return freeze({ id: createId(), operationId: createId(), name: file.name, mimeType: file.bytes.type, byteLength: file.bytes.size,...(options.images?{image:{version:1 as const,status:'pending' as const}}:{}) });
});
if (new Set(resources.flatMap(resource => [resource.id, resource.operationId])).size !== resources.length * 2 || resources.some(resource => editor.getSnapshot().runtime.resources.has(resource.id) || editor.getSnapshot().runtime.orphanFiles?.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));
staged = resources; resources.forEach((resource, index) => { fileWrites.set(resource.id, files[index].bytes);if(files[index].defaultSize)stagedDefaultSizes.add(resource.id); });
try {
const handles = freeze(resources.map(resource => ({ id: resource.id, src: options.assets!.sourceURL(resource.id) })));
const result = editor.transaction(tx => callback(handles, tx), { label: 'Add images' });
if (result.status === 'rejected' || !result.operationIds.length) for (const resource of resources) fileWrites.delete(resource.id);
return result;
} finally { staged = []; for (const resource of resources) if (!editor.getSnapshot().runtime.resources.has(resource.id)) fileWrites.delete(resource.id); }
} finally { staged = []; stagedDefaultSizes.clear();for (const resource of resources) if (!editor.getSnapshot().runtime.resources.has(resource.id)) fileWrites.delete(resource.id); }
},
async displayResource(id: Id) {
if (closing || closed) throw fault('engine_closing');
@@ -810,12 +871,12 @@ export async function openProjectEngine(options: EngineOptions) {
getSnapshot: editor.getSnapshot, persist,
cancelRecovery:async()=>{await recovery?.cancel();},
getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }),
retry: async () => { if(recovery)await recovery.cancel();assertWritable();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}await scheduler.resume();await runTracker.retry(); },
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()]);while(imageTasks.size)await Promise.all([...imageTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); },
retry: async () => { if(recovery)await recovery.cancel();assertWritable();retryDefaultSizes();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}await scheduler.resume();await runTracker.retry(); },
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()]);while(imageTasks.size)await Promise.all([...imageTasks.values()]);while(sizingTask)await sizingTask; await scheduler.settle(); await runTracker.settle(); await persist(); },
async close() {
if (closed) return;
closing = true;orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].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()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);
closing = true;unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].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()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);while(sizingTask)await sizingTask;
recovery?.controller.abort();recovery?.release();
for (const release of [...displays]) release();
// Failure keeps the leader/recovery material available for an explicit retry of close.
@@ -1,4 +1,5 @@
import type { OperationIntent } from '../canvas-core/types.ts';
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
import { validateHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts';
import { fault } from '../canvas-core/values.ts';
import type { EngineSnapshot } from './contracts.ts';
@@ -12,7 +13,7 @@ export function historyEventSource(snapshot:EngineSnapshot,pending:readonly Oper
const content=pending.filter(intent=>intent.changes.length).sort((a,b)=>a.sequence-b.sequence),first=content[0];if(!first)return{events:[]};
const records=snapshot.runtime.ledger.operations,audits=[...snapshot.runtime.recoveries.values()],owners=audits.filter(audit=>audit.decisions.some(decision=>decision.replacementId===first.id));
if(owners.length>1)throw fault('recovery_history_adoption_ambiguous',[first.id]);
const interpreter=(record:OperationRecord)=>{if(record.intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[record.intent.id]);};
const interpreter=(record:OperationRecord)=>{if(record.intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[record.intent.id]);if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);};
const pendingIds=new Set(content.map(intent=>intent.id));
const order=(events:readonly HistorySourceEvent[])=>{const seen=new Set<string>(),active:string[]=[];for(const event of events)if(event.record){const id=event.record.intent.id;if(seen.has(id))throw fault('recovery_history_event_order',[id]);seen.add(id);if(pendingIds.has(id))active.push(id);}if(active.length!==content.length||active.some((id,index)=>id!==content[index].id))throw fault('recovery_history_event_order');};
const native=(record:OperationRecord):HistorySourceEvent=>{interpreter(record);const transition=record.intent.historyTransition;if(!transition)throw fault('recovery_history_transition_missing',[record.intent.id]);validateHistoryTransition(transition);return{record,transition};};
@@ -7,6 +7,7 @@ import { rebaseIntent, type IntentContext } from './change-rebase.ts';
import { sameHistory, type HistoryReplay, type HistoryReplayEvent } from './history-replay.ts';
import { historyEventSource } from './history-event-source.ts';
import type { EngineSnapshot } from './contracts.ts';
import { planCreationSize, validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
/** Native history events are reconstructed from evidence; all cursors remain private until the recovery root is accepted. */
export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:readonly OperationIntent[],registry:Registry,limit:number){
@@ -21,6 +22,7 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
const baseline=history,replayEvents:HistoryReplayEvent[]=[],adoptionRevision=snapshot.rootRevision+1;
function recordStep(before:HistoryState,entryId:Id|undefined,operationId?:Id){const transition=describeHistoryTransition(before,history,adoptionRevision,entryId,!!operationId);if(transition)replayEvents.push(operationId?freeze({kind:'operation' as const,operationId,transition}):freeze({kind:'adjustment' as const,transition}));}
let next=0;const touched=new Set<Id>(),retired=new Set<Id>();let remap:((change:Change)=>Change)|undefined;
let sizeHistory: HistoryState | undefined;
const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap<Id,HistoryEntry>)=>Object.freeze({undo:freeze([...undo]),redo:freeze([...redo]),entries:readMap(entries)});
const eventFor=(intent:OperationIntent)=>{const event=events[next];if(!event||event.record?.intent.id!==intent.id)throw fault('recovery_history_event_order',[intent.id]);return event;};
function reviewPrefix(entry:HistoryEntry,document:Document):readonly Change[]{
@@ -61,7 +63,7 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
if(!record)applyAdjustment(event);
else{
if(pendingIds.has(record.intent.id))throw fault('recovery_history_event_order',[record.intent.id]);if(!['applied','elided'].includes(record.resolution??''))throw fault('recovery_history_event_unresolved',[record.intent.id]);
if(record.intent.origin==='user')addUser(record.intent,record.intent.changes,record.intent.id,false);else if(['undo','redo'].includes(record.intent.origin))addConfirmedTraversal(record.intent);else throw fault('recovery_history_event_origin_unsupported',[record.intent.id]);
if(record.intent.historyEffect)addConfirmedSize(record.intent);else if(record.intent.origin==='user')addUser(record.intent,record.intent.changes,record.intent.id,false);else if(['undo','redo'].includes(record.intent.origin))addConfirmedTraversal(record.intent);else throw fault('recovery_history_event_origin_unsupported',[record.intent.id]);
}
recordStep(before,event.transition.entryId,record?.intent.id);next++;
}
@@ -81,9 +83,27 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
const entries=new Map(history.entries),map=incarnationRemap(before,after);for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(map)}));
entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,intent.id]}));source.pop();destination.push(id);history=state(undo,redo,entries);
}
function addConfirmedSize(intent: OperationIntent) {
validateCreationSizeEffect(intent.historyEffect);
const event = eventFor(intent), entries = new Map(history.entries);
if (event.transition.entryId !== intent.historyEffect.entryId || !equal(event.before!.undo, event.after!.undo) || !equal(event.before!.redo, event.after!.redo)) throw fault('recovery_history_size_conflict');
for (const patch of event.transition.entries) {
const current = entries.get(patch.id), before = event.before!.entries.get(patch.id), after = event.after!.entries.get(patch.id);
if (!current || !before || !after || !equal(current.changes, before.changes) || current.label !== before.label || current.groupId !== before.groupId) throw fault('recovery_history_size_conflict', [patch.id]);
entries.set(patch.id, freeze({ ...current, changes: after.changes, operationIds: patch.id === intent.historyEffect.entryId ? [...current.operationIds, intent.id] : current.operationIds }));
}
history = state(history.undo, history.redo, entries);
}
return{
project(document:Document,intent:OperationIntent,context:IntentContext,keep:boolean){
if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined;
if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined;sizeHistory=undefined;
if (intent.historyEffect) {
validateCreationSizeEffect(intent.historyEffect);
if (!keep) return { document, changes: [] as readonly Change[] };
const effect = intent.historyEffect, plan = planCreationSize(document, history, registry, effect.entryId, effect.target, effect.size);
sizeHistory = plan.history;
return { document: plan.document, changes: plan.changes };
}
if(['undo','redo'].includes(intent.origin)){
if(!id)throw fault('recovery_history_traversal_order');const entry=history.entries.get(id);
if(!keep){
@@ -100,7 +120,17 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
},
accept(intent:OperationIntent,replacement:OperationIntent|undefined,keep:boolean,before:Document,after:Document){
if(!intent.changes.length)return;const previous=history,event=eventFor(intent),id=event.transition.entryId;
if(intent.origin==='user'){if(replacement)addUser(intent,replacement.changes,replacement.id,true,before);else skipUser(intent,before);}
if (intent.historyEffect) {
if (keep) {
if (!sizeHistory) throw fault('recovery_history_size_conflict');
const entries = new Map(sizeHistory.entries), owner = entries.get(intent.historyEffect.entryId);
if (!owner) throw fault('recovery_history_size_conflict');
if (replacement) entries.set(owner.id, freeze({ ...owner, operationIds: [...owner.operationIds, replacement.id] }));
for (const [entryId, entry] of entries) if (!equal(entry, history.entries.get(entryId))) touched.add(entryId);
history = state(sizeHistory.undo, sizeHistory.redo, entries);
}
}
else if(intent.origin==='user'){if(replacement)addUser(intent,replacement.changes,replacement.id,true,before);else skipUser(intent,before);}
else if(['undo','redo'].includes(intent.origin)&&id&&keep){
const entries=new Map(history.entries),undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo,entry=entries.get(id)!;source.pop();
if(replacement){const changes=intent.origin==='undo'?invert(replacement.changes):replacement.changes;try{planHistoryChanges(after,intent.origin==='undo'?changes:invert(changes),registry);}catch{throw fault('recovery_history_traversal_conflict',[id]);}if(remap)for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)}));entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,replacement.id]}));destination.push(id);}else{entries.delete(id);retired.add(id);}
+2
View File
@@ -1,4 +1,5 @@
import { withLocalLifecycles } from './elision.ts';
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
import type { Change, Document, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
import { applyChanges, validateDocument } from '../canvas-core/document.ts';
import { equal, fault } from '../canvas-core/values.ts';
@@ -55,6 +56,7 @@ export function replayPending(base: Document, ledger: LedgerSnapshot, registry:
let document = withLocalLifecycles(base, ledger);
for (const record of [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) {
if (record.phase === 'resolved' || !['document.change','run.apply'].includes(record.intent.operationType)) continue;
if (record.intent.historyEffect) validateCreationSizeEffect(record.intent.historyEffect);
for (const change of record.intent.changes) {
if(change.kind==='field'){const owner=document.elements.get(change.target.id)?.ownerCanvasId;if(owner&&record.intent.canvasIds?.length&&!record.intent.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);}
if (satisfied(document, change)) continue;
+2 -2
View File
@@ -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|4|5,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
if(![1,2,3,4,5].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|5|6,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
if(![1,2,3,4,5,6].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);}
+10 -4
View File
@@ -2,6 +2,8 @@ import {validateOrphanFile,type OrphanFile} from './orphan-files.ts';
import {importFiles} from './import-file-retention.ts';
import {resourceRetentionPins} from './resource-retention.ts';
import { sealRecoveryImage } from './recovery-encoding.ts';
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
import { validateDefaultImageSizing } from './default-image-size.ts';
import { validateImportJob, type ImportJob } from './imports.ts';
import { validateElisions, withLocalLifecycles } from './elision.ts';
import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
@@ -25,7 +27,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage {
operations: [...snapshot.operations],
runtime: { ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:[...snapshot.runtime.orphanFiles]}:{}), 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.orphanFiles?.size?5:[...(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);
return sealRecoveryImage([...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(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; }
@@ -77,6 +79,7 @@ function intent(value: unknown, id: Id): OperationIntent {
else {const target=object(action.target);string(target.id);integer(target.incarnation,1);string(action.outputId);if(!strings(raw.canvasIds).includes(string(action.targetCanvasId)))throw fault('corrupt_recovery_image');}
}
if(raw.historyTransition!==undefined)historyTransition(raw.historyTransition);
if(raw.historyEffect!==undefined){const effect=object(raw.historyEffect);integer(effect.version,1);if(raw.origin!=='asset-resolution'||kind!=='document.change')throw fault('corrupt_recovery_image');if(effect.version===1)validateCreationSizeEffect(effect);}
integer(raw.operationSchemaVersion, 1); integer(raw.sequence, 1); string(raw.origin);
strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes);
return freeze(value as OperationIntent);
@@ -88,18 +91,20 @@ 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,4,5].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
if (![1,2,3,4,5,6].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.
const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger);
const operations = table(raw.operations, intent),restoredHistory=readHistory(history,operations);
if(image.formatVersion<6&&[...operations.values()].some(intent=>intent.historyEffect))throw fault('unsupported_recovery_version');
for(const operation of operations.values())if(operation.historyTransition?.version===1&&(operation.historyTransition.rootRevision<1||operation.historyTransition.rootRevision>image.rootRevision))throw fault('corrupt_recovery_image');
if(image.formatVersion===1&&array(runtime.imports??[]).length)throw fault('unsupported_recovery_version');
const records = table<OperationRecord>(ledger.operations, (value, id) => {
const raw = object(value); intent(raw.intent, id); strings(raw.blockers); object(raw.basePresence);
if (((object(raw.intent).operationType === 'document.change'||object(raw.intent).operationType==='run.apply'&&array(object(raw.intent).changes).length>0) && !operations.has(id)) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image');
if(operations.has(id)&&!equal(operations.get(id)!.historyTransition,(raw.intent as OperationIntent).historyTransition))throw fault('corrupt_recovery_image');
if(operations.has(id)&&!equal(operations.get(id)!.historyEffect,(raw.intent as OperationIntent).historyEffect))throw fault('corrupt_recovery_image');
if(raw.resolution!==undefined&&!['applied','not-applied','superseded','discarded','satisfied','elided'].includes(string(raw.resolution)))throw fault('corrupt_recovery_image');
return freeze(value as OperationRecord);
});
@@ -165,6 +170,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.sizing!==undefined){if(image.formatVersion<6)throw fault('unsupported_recovery_version');const sizing=object(raw.sizing);integer(sizing.version,1);if(sizing.version===1)validateDefaultImageSizing(sizing);}
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.image!==undefined){const image=object(raw.image);integer(image.version,1);if(image.version===1){if(!['pending','ready','failed'].includes(String(image.status)))throw fault('corrupt_recovery_image');if(image.status==='ready'){integer(image.width,1);integer(image.height,1);if(!Number.isSafeInteger((image.width as number)*(image.height as number))||!['decode','server'].includes(String(image.source)))throw fault('corrupt_recovery_image');}if(image.status==='failed')string(image.code);}}
if (raw.digest !== undefined && !/^[a-f0-9]{64}$/.test(string(raw.digest))) throw fault('corrupt_recovery_image');
@@ -200,7 +206,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
const original=readHistory(object(backup.history),operations);
const composed=replayHistoryEvidence(original,replay,id=>{
const record=records.get(id);
return!!record?.intent.changes.length&&['user','undo','redo'].includes(record.intent.origin)&&(local.has(id)||['applied','elided'].includes(record.resolution??''));
return!!record?.intent.changes.length&&(['user','undo','redo'].includes(record.intent.origin)||record.intent.origin==='asset-resolution'&&!!record.intent.historyEffect)&&(local.has(id)||['applied','elided'].includes(record.resolution??''));
});
const expected=applyHistoryTransition(original,raw.historyTransition as HistoryTransition,'forward');
if(!sameHistory(composed,expected))throw fault('corrupt_recovery_image');
@@ -231,7 +237,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
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 orphanFiles=table<OrphanFile>(runtime.orphanFiles??[],(file,id)=>{validateOrphanFile(file,id);return freeze(file);});
if(orphanFiles.size&&image.formatVersion!==5)throw fault('unsupported_recovery_version');for(const id of orphanFiles.keys())if(resources.has(id)||importFiles(imports).has(id))throw fault('local_file_identity_conflict',[id]);
if(orphanFiles.size&&image.formatVersion<5)throw fault('unsupported_recovery_version');for(const id of orphanFiles.keys())if(resources.has(id)||importFiles(imports).has(id))throw fault('local_file_identity_conflict',[id]);
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({ orphanFiles, imports, recoveries, runs, resources, resourcePins, ledger: restoredLedger, acknowledged, versions, receipts, conflicts: table(runtime.conflicts, string) }),
+1 -1
View File
@@ -19,7 +19,7 @@ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,
for(const resource of runtime.resources.values())if(resource.image&&(resource.image.version!==1||resource.image.status==='pending'))add(resource.id,resource.image.version!==1?'image-decoder-unavailable':'image-decode');
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');
return Object.freeze({complete:current.complete&&base.complete&&![...runtime.resources.values()].some(resource=>resource.image&&resource.image.version!==1),pins:readMap(new Map([...pins].map(([id,owners])=>[id,readSet(owners)])))});
return Object.freeze({complete:current.complete&&base.complete&&![...runtime.resources.values()].some(resource=>resource.image&&resource.image.version!==1||resource.sizing&&resource.sizing.version!==1),pins:readMap(new Map([...pins].map(([id,owners])=>[id,readSet(owners)])))});
}
/** Runs inside the editor's single publication boundary, including reconcile and metadata commits. */
+4 -2
View File
@@ -1,4 +1,5 @@
import type {ImageDecodeState} from './image-decoder.ts';
import type { DefaultImageSizing } from './default-image-size.ts';
import type { Change, CoreSnapshot, Document, ElementRecord, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
import { historyTransitionChanges } from '../canvas-core/history-transition.ts';
import { ancestors } from '../canvas-core/document.ts';
@@ -7,7 +8,7 @@ import type { OperationStatus } from './scheduler.ts';
import { readMap, readSet } from '../canvas-core/values.ts';
export type StoredAsset = { readonly id: Id; readonly url: string; readonly width: number; readonly height: number; readonly bytes: number; readonly mime_type: string };
export type ResourceRecord = { readonly id: Id; readonly operationId: Id; readonly name: string; readonly mimeType: string; readonly byteLength: number; readonly image?:ImageDecodeState; readonly digest?: string; readonly retirement?: {readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number}; readonly stored?: StoredAsset };
export type ResourceRecord = { readonly id: Id; readonly operationId: Id; readonly name: string; readonly mimeType: string; readonly byteLength: number; readonly sizing?: DefaultImageSizing; readonly image?:ImageDecodeState; readonly digest?: string; readonly retirement?: {readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number}; readonly stored?: StoredAsset };
export type DisplayHandle = { readonly url: string; release(): void };
export type AssetPort = {
sourceURL(resourceId: Id): string;
@@ -16,7 +17,7 @@ export type AssetPort = {
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
accept(request: FrozenRequest, status: OperationStatus, resource: ResourceRecord): StoredAsset;
};
export type StagedFile = { readonly bytes: Blob; readonly name: string };
export type StagedFile = { readonly bytes: Blob; readonly name: string; readonly defaultSize?: boolean };
export type ResourceHandle = { readonly id: Id; readonly src: string };
export const maxResourceBytes = 20 * 1024 * 1024;
@@ -105,6 +106,7 @@ export function resourcePins(snapshot: CoreSnapshot, ledger: LedgerSnapshot, reg
else changes(record.intent.changes, `operation:${record.intent.id}`);
}
const pending=[...ledger.operations.values()].filter(record=>record.phase!=='resolved'),earliest=pending.reduce((value,record)=>Math.min(value,record.intent.sequence),Infinity);
for (const record of ledger.operations.values()) if (record.intent.historyEffect && record.intent.historyEffect.version !== 1) complete = false;
for(const record of ledger.operations.values())if(record.intent.sequence>=earliest&&record.intent.historyTransition){
if(record.intent.historyTransition.version!==1){complete=false;continue;}
changes(historyTransitionChanges(record.intent.historyTransition),`history-transition:${record.intent.id}`);
+3 -3
View File
@@ -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 | 4 | 5; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6; 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 StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string};
@@ -43,7 +43,7 @@ function purgedFileIds(image:RecoveryImage):Set<string>{
return result;
}
function validateImage(image: RecoveryImage) {
if (![1,2,3,4,5].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
if (![1,2,3,4,5,6].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); });
@@ -149,7 +149,7 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?:
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===5)for(const [id,file]of payload?.runtime?.orphanFiles??[]){if(file.version!==1||file.id!==id||!Number.isSafeInteger(file.byteLength)||file.byteLength!<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest))throw fault('invalid_file_deletion');own(id,file,true);}
if(image.formatVersion>=5)for(const [id,file]of payload?.runtime?.orphanFiles??[]){if(file.version!==1||file.id!==id||!Number.isSafeInteger(file.byteLength)||file.byteLength!<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest))throw fault('invalid_file_deletion');own(id,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);
+32 -1
View File
@@ -11,6 +11,35 @@ import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts';
import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts';
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
async function defaultImageSizeCase(name, assert) {
const storage = createBrowserStorage({ name: `${name}-default-size`, checkpointEvery: 3 }), images = createBrowserImageDecoder();
const scope = { accountId: 'sizing-account', workspaceId: 'sizing-workspace', projectId: 'sizing-project' }, registry = createRegistry(builtinComponents());
const original = createDocument(scope.projectId, [{ id: 'root', parent: null }], []), receipts = new Map(), writes = [], queries = [], uploads = [];
let server = original, version = 1, next = 0, engine;
const persistence = {
compile(intent, id, snapshot) { return { payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: { root: snapshot.runtime.versions.get('root') } }; },
async submit(request) { writes.push(request); const { intent } = JSON.parse(request.body); server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['root', ++version]]) }); return intent.historyEffect ? { outcome: 'unresolved', blocked: true, error: 'lost_size_ack' } : { outcome: 'applied' }; },
async query(request) { queries.push(request); return receipts.has(request.id) ? { outcome: 'applied' } : { outcome: 'unresolved', blocked: true }; },
canonical(request) { return receipts.get(request.id); },
};
const assets = { sourceURL: id => `/asset/${id}`, displayURL: id => `/asset/${id}`, async upload(request, bytes) { uploads.push(request);return { outcome: 'applied', canonical: { id: JSON.parse(request.body).resource_id, bytes: bytes.size, mime_type: bytes.type, width: 4, height: 2, url: '/stored' } }; }, async query() { throw Error('unexpected asset query'); }, accept: (_request, status) => status.canonical };
const options = { scope, registry, document: original, versions: new Map([['root', 1]]), storage, images, assets, persistence, createId: () => `size-${++next}` };
try {
engine = await openProjectEngine(options); const canvas = new OffscreenCanvas(4, 2); canvas.getContext('2d').fillRect(0, 0, 4, 2); const bytes = await canvas.convertToBlob({ type: 'image/png' });
const result = engine.stageFiles([{ name: 'pixel.png', bytes, defaultSize: true }], ([ref], tx) => tx.dispatch({ type: 'element.create', element: { id: 'picture', incarnation: 1, componentId: 'image', dataVersion: 1, structure: 'node', ownerCanvasId: 'root', data: { title: 'Picture', text: '', assetId: ref.id, src: ref.src }, geometry: { x: 0, y: 0, width: 260, height: 200, rotation: 0 } } }));
assert(result.status === 'applied', 'default image admission failed'); await engine.settle();
assert(engine.getSnapshot().document.elements.get('picture').geometry.height === 130 && engine.getSnapshot().history.undo.length === 1, 'decode did not enrich the original creation');
const request = writes.find(request => JSON.parse(request.body).intent.historyEffect); assert(!!request, 'size change was never submitted'); const body = request.body;
await engine.close(); const owner = await storage.claim(scope), cut = await storage.recover(owner); assert(cut.formatVersion === 6, 'size effect did not require recovery v6'); decodeRecovery(cut, registry); await owner.release();
engine = await openProjectEngine(options); await engine.settle();
assert(queries.some(query => query.id === request.id && query.body === body), 'lost size receipt did not query the frozen original request');
assert(writes.filter(write => write.id === request.id).length === 1, 'lost size receipt repeated the write');
assert(engine.editor.history.undo().status === 'applied', 'sized creation could not undo after reopening'); await engine.settle(); assert(!server.elements.has('picture'), 'undo left the server image behind');
assert(engine.editor.history.redo().status === 'applied', 'sized creation could not redo after reopening'); await engine.settle();
assert(server.elements.get('picture').incarnation === 2 && server.elements.get('picture').geometry.height === 130, 'redo lost size or reused a deleted incarnation'); assert(uploads.length === 1, 'size recovery repeated the upload');
} finally { await engine?.close(); images.close(); storage.close(); indexedDB.deleteDatabase(`${name}-default-size`); }
}
async function imageDecodeWorkerCase(assert){
const NativeWorker=globalThis.Worker;let active=0,peak=0,created=0;globalThis.Worker=class extends NativeWorker{constructor(...args){super(...args);active++;created++;peak=Math.max(peak,active);this.live=true;}terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
const pool=createBrowserImageDecoder();try{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),results=await Promise.all([pool.decode(bytes),pool.decode(bytes),pool.decode(bytes)]);assert(results.every(size=>size.width===4&&size.height===2),'real bitmap decoder returned incorrect dimensions');assert(peak===2&&active===0&&created===3,'image worker concurrency/cleanup failed');let code;try{await pool.decode(new Blob(['not an image'],{type:'image/png'}));}catch(error){code=error.code;}assert(code==='image_decode_failed'&&active===0,'invalid image did not fail or leaked a worker');const cancel=new AbortController(),pending=pool.decode(bytes,cancel.signal),cancelled=pending.then(()=>{throw Error('cancelled image returned dimensions');},error=>assert(error.code==='image_decode_aborted','image cancellation code changed'));cancel.abort();await cancelled;assert(active===0,'cancelled image worker leaked');}finally{pool.close();globalThis.Worker=NativeWorker;}
@@ -629,7 +658,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: 6 } }), 'unsupported_recovery_version');
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 7 } }), '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.
@@ -678,6 +707,8 @@ export async function run() {
results.push('scoped paged inventory finds legacy orphans without hiding references; orphan v5 deletion rolls back faults, retries, isolates projects and forbids byte identity reuse');
await resourceRecoveryCase(name, assert);
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
await defaultImageSizeCase(name, assert);
results.push('real image Worker enriches creation history in recovery v6; IndexedDB reopening queries a lost size ACK with the original body, then undo/redo preserves size and upload identity');
await imageDecodeWorkerCase(assert);
results.push('real module image workers decode intrinsic PNG dimensions with bounded concurrency, reject bad bytes, abort safely and release their bitmap workers');
await digestWorkerCase(assert);
+1 -1
View File
@@ -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.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.throws(()=>sealRecoveryImage(6,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.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.throws(()=>sealRecoveryImage(7,1,1,{}),/corrupt_recovery_image/);
});
+6 -2
View File
@@ -244,8 +244,12 @@ try {
await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelectorAll('.react-flow__node').length === 5 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload');
const pendingImageId=await evaluate(`document.querySelector('[data-headless-canvas] img[src^="blob:"]').closest('.react-flow__node').getAttribute('data-id')`);
await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'explicit fit did not use locally decoded 4:2 aspect ratio');await click('撤销');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='200px'`,'fit undo did not restore default dimensions');assert.equal(uploads.length<=1,true);
console.log('PASS decoded image metadata is available before upload ACK; explicit ratio fit and its undo preserve the image identity');
await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'default image size did not automatically use locally decoded 4:2 aspect ratio before upload ACK');
await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整');
await click('撤销');await until(`!document.querySelector('.react-flow__node[data-id="${pendingImageId}"]')`,'one undo did not remove the automatically sized creation');
await click('重做');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]')?.style.height==='130px'`,'redo lost the enriched creation dimensions');assert.equal(uploads.length<=1,true);
await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="${pendingImageId}"]');const content=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return content?.style.height==='130px';})()`,'image content retained a stale measured height after automatic sizing and redo');
console.log('PASS default image sizing precedes upload ACK; creation undo/redo includes decoded dimensions without a second history unit or upload');
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64'));
for (let i = 0; i < 100 && !uploads.length; i++) await pause(20);
assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 5);
+118 -2
View File
@@ -47,15 +47,131 @@ function fixture() {
open: extra => openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, assets, persistence, createId: () => `id-${++counter}`, ...extra }),
};
}
function addImage(engine, id = 'picture') {
function addImage(engine, id = 'picture', defaultSize = false) {
let resource;
const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }) }], ([ref], tx) => {
const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }), defaultSize }], ([ref], tx) => {
resource = ref;
tx.dispatch({ type: 'element.create', element: node(id, { assetId: ref.id, src: ref.src }) });
});
assert.equal(result.status, 'applied'); return resource;
}
test('automatic image sizing publishes with creation history across intervening edits and survives reopening', async () => {
const f = fixture(), gate = deferred(), entered = deferred(); let calls = 0;
const images = { decode: async () => { calls++; entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } };
let engine = await f.open({ images });
try {
const ref = addImage(engine, 'picture', true); await entered.promise;
const observations = [];
const unsubscribe = engine.editor.subscribe((_commit, snapshot) => { if (snapshot.document.elements.get('picture')?.geometry.height === 50) observations.push(snapshot); });
const target = { id: 'picture', incarnation: 1 };
engine.editor.dispatch({ type: 'element.geometry', target, patch: { x: 70 } });
engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'Renamed before decode' } });
const undo = [...engine.getSnapshot().history.undo]; gate.resolve(); await engine.settle();
const root = engine.getSnapshot(); assert.equal(root.document.elements.get('picture').geometry.height, 50);
assert.equal(root.document.elements.get('picture').geometry.x, 70); assert.deepEqual(root.history.undo, undo);
assert.equal(root.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
assert.ok(observations.length > 0);
for (const snapshot of observations) {
assert.equal(snapshot.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
assert.equal(snapshot.history.entries.get(undo[0]).changes[0].after.geometry.height, 50);
assert.equal([...snapshot.operations.values()].some(intent => intent.historyEffect), true);
}
unsubscribe();
const effect = [...root.operations.values()].find(intent => intent.historyEffect);
assert.equal(effect.origin, 'asset-resolution'); assert.equal(effect.historyTransition.entries.length, 2);
assert.equal(f.image().formatVersion, 6); decodeRecovery(f.image(), registry);
for (const version of [1, 2, 3, 4, 5]) { const bad = structuredClone(f.image()); bad.formatVersion = version; assert.throws(() => decodeRecovery(bad, registry), /unsupported_recovery_version/); }
await engine.close(); engine = await f.open({ images });
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
assert.equal(engine.editor.history.undo().status, 'applied');
assert.equal(engine.editor.history.undo().status, 'applied');
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.x, 0);
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
assert.equal(engine.editor.history.undo().status, 'applied'); assert.equal(engine.getSnapshot().document.elements.has('picture'), false);
assert.equal(engine.editor.history.redo().status, 'applied');
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
await engine.settle(); assert.equal(calls, 1); assert.equal(f.uploads.length, 1);
} finally { gate.resolve(); await engine.close(); }
});
test('a committed user resize permanently revokes default sizing even if undone before decoding', async () => {
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
try {
const ref = addImage(engine, 'picture', true); await entered.promise;
engine.editor.dispatch({ type: 'element.geometry', target: { id: 'picture', incarnation: 1 }, patch: { height: 75 } });
assert.equal(engine.editor.history.undo().status, 'applied'); gate.resolve(); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'revoked');
assert.equal([...engine.getSnapshot().operations.values()].some(intent => intent.historyEffect), false);
decodeRecovery(f.image(), registry);
} finally { gate.resolve(); await engine.close(); }
});
test('a pending sizing grant and source bytes survive closing before decode completion', async () => {
const f = fixture(), gate = deferred(), entered = deferred(); let engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
const ref = addImage(engine, 'picture', true); await entered.promise;
const closing = engine.close(); gate.resolve(); await closing;
assert.equal(f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing.grants[0].status, 'pending');
engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) } });
try { await engine.settle(); assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50); assert.equal(engine.getSnapshot().history.undo.length, 1); decodeRecovery(f.image(), registry); }
finally { await engine.close(); }
});
test('default sizing waits for a geometry gesture to finish and retains its committed movement', async () => {
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
try {
addImage(engine, 'picture', true); await entered.promise;
engine.sessions.attach('view'); assert.equal(engine.sessions.begin('view', [{ id: 'picture', incarnation: 1 }]).enabled, true);
engine.sessions.update('view', { id: 'picture', incarnation: 1 }, { x: 180 }); gate.resolve(); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
assert.equal(engine.sessions.commit('view').status, 'applied'); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.x, 180);
assert.equal(engine.getSnapshot().history.undo.length, 2);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
} finally { gate.resolve(); await engine.close(); }
});
test('replacing an image resource revokes its pending grant and a late decoder cannot change geometry', async () => {
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
try {
const ref = addImage(engine, 'picture', true); await entered.promise;
engine.editor.dispatch({ type: 'element.data', target: { id: 'picture', incarnation: 1 }, patch: { assetId: 'replacement' } });
gate.resolve(); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'revoked');
} finally { gate.resolve(); await engine.close(); }
});
test('a denied automatic resize records a recoverable block and explicit retry consumes the original grant', async () => {
const f = fixture(); let denied = true;
const engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) }, policy(command) { return command.type === 'element.geometry' && denied ? { enabled: false, code: 'forbidden', targets: ['picture'] } : { enabled: true }; } });
try {
const ref = addImage(engine, 'picture', true); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'blocked');
denied = false; await engine.retry(); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
assert.equal(engine.getSnapshot().history.undo.length, 1); assert.equal(f.uploads.length, 1);
} finally { await engine.close(); }
});
test('future default-sizing metadata stays opaque, blocks GC and permits unrelated editing', async () => {
const f = fixture(); let engine = await f.open(); const ref = addImage(engine); await engine.settle(); await engine.close();
const future = { version: 2, opaque: { reference: 'keep' } }; f.image().formatVersion = 6; f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing = future;
engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) } });
try {
await engine.settle(); assert.equal(engine.getResourcePins().complete, false);
assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).sizing, future);
assert.equal(engine.editor.dispatch({ type: 'element.data', target: { id: 'picture', incarnation: 1 }, patch: { title: 'Edited' } }).status, 'applied');
await engine.settle(); decodeRecovery(f.image(), registry);
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
} finally { await engine.close(); }
});
test('pending images publish immediately, bytes share the durable cut, and independent canvas edits overtake upload', async () => {
const f = fixture(), gate = deferred(), upload = f.assets.upload;
f.assets.upload = async (...args) => { await gate.promise; return upload(...args); };
@@ -21,6 +21,57 @@ function fixture(original=base()){
}
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
test('pending default size effects recover with preceding creation and movement, preserving one creation undo unit', async () => {
const f = fixture(), engine = await f.open();
try {
engine.editor.dispatch({ type: 'element.create', element: node('sized') });
const entryId = engine.getSnapshot().history.undo.at(-1);
engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } });
const root = engine.getSnapshot();
assert.equal(engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision).status, 'applied');
await engine.settle();
const session = await engine.prepareRecovery(), plan = session.plan(choices(engine));
assert.equal(plan.history.undo.length, 2); assert.equal(plan.document.elements.get('sized').geometry.height, 40);
f.reject = false; await session.resolve(plan); await engine.settle(); decodeRecovery(f.image(), registry);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(f.server().elements.get('sized').geometry.x, 100); assert.equal(f.server().elements.get('sized').geometry.height, 40);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false);
assert.equal(engine.editor.history.redo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.get('sized').geometry.height, 40);
} finally { await engine.close(); }
});
test('discarding a pending size effect retains the uncorrected creation and movement histories', async () => {
const f = fixture(), engine = await f.open();
try {
engine.editor.dispatch({ type: 'element.create', element: node('sized') }); const entryId = engine.getSnapshot().history.undo.at(-1);
engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } });
const root = engine.getSnapshot(), result = engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision);
await engine.settle(); const selected = choices(engine); selected.set(result.operationIds[0], 'discard-local');
const session = await engine.prepareRecovery(), plan = session.plan(selected);
assert.equal(plan.document.elements.get('sized').geometry.height, 80); assert.equal(plan.history.undo.length, 2);
f.reject = false; await session.resolve(plan); await engine.settle();
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(f.server().elements.get('sized').geometry.height, 80);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false);
} finally { await engine.close(); }
});
test('a confirmed size effect is retained while an earlier independent pending edit is recovered', async () => {
const f = fixture(); f.reject = false; const engine = await f.open();
try {
engine.editor.dispatch({ type: 'element.create', element: node('sized', 'child') }); await engine.settle();
const entryId = engine.getSnapshot().history.undo.at(-1); f.reject = true;
engine.editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'Pending root title' } }); await engine.settle();
f.reject = false; const root = engine.getSnapshot();
const result = engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision);
await engine.settle(); assert.equal(engine.getSnapshot().runtime.ledger.operations.get(result.operationIds[0]).resolution, 'applied');
const session = await engine.prepareRecovery(), plan = session.plan(choices(engine));
await session.resolve(plan); await engine.settle(); decodeRecovery(f.image(), registry);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false);
} finally { await engine.close(); }
});
test('pending create and later edits recover with absent baselines, new keys and lifecycle-safe undo/redo',async()=>{
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle();
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));