Resolve default image sizes atomically with creation history and recovery
This commit is contained in:
@@ -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) });
|
||||
}
|
||||
@@ -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);}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);}
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user