From fff621371435e7432a7d6284d5914cc2d096dd7d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 03:00:21 +0800 Subject: [PATCH] Persist partial-load edits and hold synchronization until a durable full base --- headless/README.md | 11 +++ src/lib/canvas-runtime/conflicts.ts | 3 +- src/lib/canvas-runtime/contracts.ts | 1 + src/lib/canvas-runtime/engine.ts | 53 +++++++--- src/lib/canvas-runtime/index.ts | 2 + src/lib/canvas-runtime/loading.ts | 59 ++++++++++++ src/lib/canvas-runtime/recovery-encoding.ts | 4 +- src/lib/canvas-runtime/recovery.ts | 12 ++- src/lib/canvas-runtime/storage-port.ts | 2 +- src/lib/canvas-runtime/storage.ts | 5 +- tests/headless/browser-storage-cases.mjs | 23 ++++- tests/headless/loading-engine.test.mjs | 101 ++++++++++++++++++++ tests/headless/package.integration.mjs | 6 +- tests/headless/recovery-encoding.test.mjs | 2 +- 14 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 src/lib/canvas-runtime/loading.ts create mode 100644 tests/headless/loading-engine.test.mjs diff --git a/headless/README.md b/headless/README.md index b639fb0..ee02f1a 100644 --- a/headless/README.md +++ b/headless/README.md @@ -34,3 +34,14 @@ This preview does not yet package the React renderer/bindings. Their legacy projection DTO types still require an explicit adapter boundary. No registry publication, release support promise, server deployment or full-goal completion is implied by this local package. + +Experimental partial-model runtime: pass a validated prefix plus `loading` in +`EngineOptions`. The marker requires recovery format 7 and keeps the scheduler held. +Loaded non-reference data fields and node geometry can be edited and undone; creation, +deletions, ownership/reference changes, uploads, imports and AI admission wait. +`engine.loading.accept(document, versions, state)` extends the captured prefix without +replacing local edits. `engine.loading.complete(document, versions, {sourceToken})` +validates the full capture and releases scheduling only after its cut is durable. +Use `{fresh:true}` only for an explicitly fresh complete read; conflicts require the +existing reviewed recovery flow. Endoleap's page still uses read-only loading previews; +page integration and foreground/background source handoff remain unfinished. diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts index 2d44a46..8c49278 100644 --- a/src/lib/canvas-runtime/conflicts.ts +++ b/src/lib/canvas-runtime/conflicts.ts @@ -1,3 +1,4 @@ +import {validateLoadingCompletion} from './loading.ts'; import { withLocalLifecycles } from './elision.ts'; import type { Change, Document, FieldChange, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; import { describeHistoryTransition, historyTransitionChanges, type HistoryTransition } from '../canvas-core/history-transition.ts'; @@ -28,7 +29,7 @@ const documentValue = (document: Document) => ({projectId:document.projectId,can /** Pure proposal. Explicit choices cover the complete pending dependency closure; no key/body is reused. */ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap, registry: Registry, createId:()=>Id,historyLimit=100): ConflictPlan { - validateCanonicalRead(snapshot.runtime.acknowledged,snapshot.runtime.versions,canonical.document,canonical.versions,registry); + if(snapshot.runtime.loading)validateLoadingCompletion(snapshot.runtime.acknowledged,snapshot.runtime.versions,snapshot.runtime.loading,canonical.document,canonical.versions,registry,true);else validateCanonicalRead(snapshot.runtime.acknowledged,snapshot.runtime.versions,canonical.document,canonical.versions,registry); if([...snapshot.runtime.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('recovery_waiting_for_receipts'); const records=[...snapshot.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload').sort((a,b)=>a.intent.sequence-b.intent.sequence); if(!records.length)throw fault('recovery_nothing_to_resolve'); diff --git a/src/lib/canvas-runtime/contracts.ts b/src/lib/canvas-runtime/contracts.ts index c5734b3..7aa4888 100644 --- a/src/lib/canvas-runtime/contracts.ts +++ b/src/lib/canvas-runtime/contracts.ts @@ -8,6 +8,7 @@ import type { ResourceRecord } from './resources.ts'; import type { RecoveryAudit } from './conflicts.ts'; export type RuntimeState = { + readonly loading?:import('./loading.ts').LoadingState; readonly orphanFiles?:ReadonlyMap; readonly imports?: ReadonlyMap; readonly ledger: LedgerSnapshot; diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 6c06065..294e00f 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -1,3 +1,4 @@ +import {assertLoadingChanges,loadingCommand,validateLoadingRuntime,validatePrefixExtension,validateLoadingCompletion,type LoadingState} from './loading.ts'; import {validImageDimensions,type ImageDecoderPort} from './image-decoder.ts'; import {unitScale} from '../canvas-core/units.ts'; import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts'; @@ -35,6 +36,7 @@ import type { StoragePort, StorageScope } from './storage-port.ts'; const digestFailures=new Set(['digest_capacity','digest_worker_failed','digest_worker_unavailable','invalid_digest_response','digest_pool_closed']); export type EngineOptions = { + loading?:LoadingState; scope: StorageScope; registry: Registry; document: Document; versions: ReadonlyMap; storage: StoragePort; displays?: DisplayCache; images?:Pick; digests?:Pick; persistence: PersistencePort; assets?: AssetPort; runs?: RunPort; createId?: () => Id; historyLimit?: number; queryBudget?:EditorOptions['queryBudget']; policy?: EditorOptions['policy']; traversalPolicy?: EditorOptions['traversalPolicy']; clock?: Clock; @@ -57,11 +59,13 @@ export type RecoverySession = { /** One project owner. Detaching a view does not close this engine or abort persistent work. */ export async function openProjectEngine(options: EngineOptions) { if (options.scope.projectId !== options.document.projectId) throw fault('project_mismatch'); + const openingLoading=options.loading?freeze(clone(options.loading)):undefined,openingVersions=readMap(options.versions); const leader = await options.storage.claim(options.scope); try { const image = await options.storage.recover(leader), recovered = image ? decodeRecovery(image, options.registry) : undefined; if (recovered && recovered.document.projectId !== options.scope.projectId) throw fault('project_mismatch'); - for (const canvas of options.document.canvases.keys()) if (!Number.isSafeInteger(options.versions.get(canvas)) || options.versions.get(canvas)! < 1) throw fault('missing_server_version', [canvas]); + for (const canvas of options.document.canvases.keys()) if (!Number.isSafeInteger(openingVersions.get(canvas)) || openingVersions.get(canvas)! < 1) throw fault('missing_server_version', [canvas]); + let acceptingLoading=false,loadingRelease:(()=>void)|undefined,loadingCompletionRevision:number|undefined; let closing = false, closed = false, durableRootRevision = image?.rootRevision ?? -1; const digestLifecycle=new AbortController(); const sourceDigest=(bytes:Blob,signal?:AbortSignal)=>{const combined=signal?AbortSignal.any([signal,digestLifecycle.signal]):digestLifecycle.signal;return options.digests?options.digests.digest(bytes,combined):importSourceDigest(bytes,combined);}; @@ -92,13 +96,16 @@ export async function openProjectEngine(options: EngineOptions) { const importRunners=new Map>(),importLoads=new Map>(),importHolds=new Mapvoid>>(); let recovery: { controller: AbortController; release(): void; cancel():Promise } | undefined; const admissionPins=(snapshot:EngineSnapshot)=>{const pins=new Map();if(copyPreparations.size)pins.set('copy-admission',[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt===undefined).map(resource=>resource.id));if(imageTasks.size)pins.set('image-decode',[...imageTasks.keys()]);return pins;}; - const assertWritable = () => { if(closing||closed)throw fault('engine_closing');if(recovery)throw fault('recovery_in_progress'); }; - const initial: RuntimeState = recovered?.runtime ?? Object.freeze({ imports:readMap(new Map()), recoveries:readMap(new Map()), runs: readMap(new Map()), acknowledged: createDocument(options.document.projectId, [...options.document.canvases.values()], [...options.document.elements.values()], options.document.tombstones), versions: readMap(options.versions), ledger: createLedger().getSnapshot(), receipts: readMap(new Map()), conflicts: readMap(new Map()), resources: readMap(new Map()), resourcePins: readMap(new Map()) }); + const assertWritable = (complete=true) => { if(options.isCurrentIdentity&&!options.isCurrentIdentity())throw fault('identity_changed');if(complete&&editor.getSnapshot().runtime.loading)throw fault('model_loading');if(closing||closed)throw fault('engine_closing');if(recovery)throw fault('recovery_in_progress'); }; + const initial: RuntimeState = recovered?.runtime ?? Object.freeze({ imports:readMap(new Map()), recoveries:readMap(new Map()), runs: readMap(new Map()), acknowledged: createDocument(options.document.projectId, [...options.document.canvases.values()], [...options.document.elements.values()], options.document.tombstones), versions: openingVersions, ...(openingLoading?{loading:openingLoading}:{}), ledger: createLedger().getSnapshot(), receipts: readMap(new Map()), conflicts: readMap(new Map()), resources: readMap(new Map()), resourcePins: readMap(new Map()) }); + validateLoadingRuntime(initial,options.registry); const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit,queryBudget:options.queryBudget, - policy(command, document) { if (closing || closed || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; return options.policy?.(command, document) ?? { enabled: true }; }, - traversalPolicy(entry, direction) { return closing || closed || recovery ? { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] } : options.traversalPolicy?.(entry, direction) ?? { enabled: true }; }, + policy(command, document) { if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if (closing || closed || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; if(editor.getSnapshot().runtime.loading){const allowed=loadingCommand(command,document,options.registry);if(!allowed.enabled)return allowed;}return options.policy?.(command, document) ?? { enabled: true }; }, + traversalPolicy(entry, direction) { if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if(closing||closed||recovery)return{enabled:false,code:closing||closed?'engine_closing':'recovery_in_progress',targets:[]};if(editor.getSnapshot().runtime.loading){try{assertLoadingChanges(entry.changes,editor.getSnapshot().runtime.acknowledged,options.registry);}catch(error){return{enabled:false,code:(error as Error).message,targets:[]};}}return options.traversalPolicy?.(entry,direction)??{enabled:true}; }, onListenerError: report, prepareRuntime:(previous,next)=>{ + if(!acceptingLoading&&!recovery&&(previous.runtime.loading||next.runtime.loading)&&(!equal(previous.runtime.loading,next.runtime.loading)||previous.runtime.acknowledged!==next.runtime.acknowledged||!equal(Object.fromEntries(previous.runtime.versions),Object.fromEntries(next.runtime.versions))))throw fault('loading_state_owned'); + validateLoadingRuntime(next.runtime,options.registry); 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; @@ -108,6 +115,7 @@ export async function openProjectEngine(options: EngineOptions) { prepareIntent: intent => applying && intent.origin==='user' ? {...intent,operationType:'run.apply',action:applying} : intent, nextOperationSequence(runtime, fallback) { let next = fallback; for (const record of runtime.ledger.operations.values()) next = Math.max(next, record.intent.sequence + 1); return next + staged.length; }, reduceRuntime(previous, next, commit) { + if(previous.loading)assertLoadingChanges(commit.changes,previous.acknowledged,options.registry); if (!commit.operationIds.length) return previous; const ledger = createLedger(previous.ledger), resources = new Map(previous.resources); const firstSequence = next.operations.get(commit.operationIds[0])!.sequence - staged.length; @@ -162,7 +170,7 @@ export async function openProjectEngine(options: EngineOptions) { } persistenceFailed = false; })(); - writing = task.catch(error => { persistenceFailed = true; throw error; }).finally(() => { writing = undefined; }); + writing = task.catch(error => { persistenceFailed = true; throw error; }).finally(() => { writing = undefined;if(!persistenceFailed&&loadingRelease&&loadingCompletionRevision!==undefined&&durableRootRevision>=loadingCompletionRevision&&!editor.getSnapshot().runtime.loading){const release=loadingRelease;loadingRelease=undefined;loadingCompletionRevision=undefined;release();} }); return writing; } function collectResources(settings:{retentionMs:number;maxFiles?:number}) { @@ -415,6 +423,15 @@ export async function openProjectEngine(options: EngineOptions) { query: (request, signal) => network.run(signal, () => ['run.start','run.cancel','run.apply'].includes(request.kind??'') ? options.runs?.query(request,signal) ?? Promise.resolve({ outcome: 'unresolved', blocked: true, error: 'run_interpreter_unavailable' }) : request.kind === 'asset.upload' ? options.assets?.query(request, signal) ?? Promise.resolve({ outcome: 'unresolved', blocked: true, error: 'asset_interpreter_unavailable' }) : options.persistence.query(request, signal)), supportsRequest: request => ['run.start','run.cancel','run.apply'].includes(request.kind??'') ? !!options.runs && request.protocolVersion === 2 : request.kind === 'asset.upload' ? !!options.assets && request.protocolVersion === 2 : options.persistence.supportsRequest?.(request) ?? request.protocolVersion === 1, complete, onError: report, clock: options.clock, }); + if(initial.loading)loadingRelease=scheduler.hold(); + function completeLoading(document:Document,versions:ReadonlyMap,settings:{fresh?:boolean;sourceToken?:string}={}):Result{ + try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active'); + validateLoadingCompletion(previous.acknowledged,previous.versions,previous.loading,document,versions,options.registry,settings.fresh,settings.sourceToken); + const acknowledged=createDocument(document.projectId,[...document.canvases.values()],[...document.elements.values()],document.tombstones),working=replayPending(acknowledged,previous.ledger,options.registry),conflicts=new Map(previous.conflicts);conflicts.delete('loading');conflicts.delete('refresh'); + acceptingLoading=true;const result=editor.reconcile(working,Object.freeze({...previous,loading:undefined,acknowledged,versions:readMap(versions),conflicts:readMap(conflicts)}),root.rootRevision); + if(result.status==='applied')loadingCompletionRevision=editor.getSnapshot().rootRevision;return result; + }catch(error){const result=reject(error);if(editor.getSnapshot().runtime.loading&&!['engine_closing','identity_changed','recovery_in_progress','loading_not_active'].includes(result.code))editor.updateRuntime(previous=>{const conflicts=new Map(previous.conflicts);conflicts.set('loading',result.code);return Object.freeze({...previous,conflicts:readMap(conflicts)});});return result;}finally{acceptingLoading=false;} + } function updateRun(id: Id, reduce: (run: RunRecord) => RunRecord) { const result = editor.updateRuntime(previous => { const current = previous.runs.get(id);if(!current)throw fault('run_missing');const runs = new Map(previous.runs);runs.set(id,freeze(reduce(current)));return Object.freeze({...previous,runs:readMap(runs)}); }); if(result.status==='rejected')throw fault(result.code,result.targets); @@ -685,11 +702,18 @@ export async function openProjectEngine(options: EngineOptions) { scheduler.start(); prepareDigests();prepareImages(); runTracker.notify([...initial.runs.keys()]); for(const job of initial.imports?.values()??[])if(['running','preparing'].includes(job.status))void loadImport(job.id); return Object.freeze({ - editor, sessions, imports, collectResources,collectImportSources,inventoryLocalFiles,collectOrphanFiles, + editor, sessions, imports, + loading:Object.freeze({getState:()=>editor.getSnapshot().runtime.loading, + accept(document:Document,versions:ReadonlyMap,state:LoadingState):Result{ + try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active');validatePrefixExtension(previous.acknowledged,previous.versions,previous.loading,document,versions,state,options.registry);const acknowledged=createDocument(document.projectId,[...document.canvases.values()],[...document.elements.values()],document.tombstones),working=replayPending(acknowledged,previous.ledger,options.registry);acceptingLoading=true;return editor.reconcile(working,Object.freeze({...previous,loading:freeze(clone(state)),acknowledged}),root.rootRevision);}catch(error){return reject(error);}finally{acceptingLoading=false;} + }, + async complete(document:Document,versions:ReadonlyMap,settings:{fresh?:boolean;sourceToken?:string}={}):Promise{const result=completeLoading(document,versions,settings);if(result.status==='applied')await persist();return result;}, + }), + collectResources,collectImportSources,inventoryLocalFiles,collectOrphanFiles, getResourcePins:()=>resourceRetentionPins(editor.getSnapshot(),options.registry,displayPins,admissionPins(editor.getSnapshot())), /** Read/preview under a scheduling hold. No request is rewritten or declared resolved here. */ async prepareRecovery(): Promise { - assertWritable(); + assertWritable(false); if(!options.persistence.read)throw fault('canonical_reader_unavailable'); const releaseScheduler=scheduler.hold(),releaseRuns=runTracker.hold(),releaseImports=holdImports(),controller=new AbortController(); const active={controller,release(){if(recovery!==active)return;recovery=undefined;releaseRuns();releaseScheduler();releaseImports();queueMicrotask(prepareImages);},async cancel(){controller.abort();active.release();}}; @@ -705,14 +729,14 @@ export async function openProjectEngine(options: EngineOptions) { const root=staged.snapshot,previous=root.runtime; const loaded=await network.run(controller.signal,()=>options.persistence.read!(controller.signal));assertCurrent(); if(editor.getSnapshot().rootRevision!==root.rootRevision)throw fault('stale_plan'); - validateCanonicalRead(previous.acknowledged,previous.versions,loaded.document,loaded.versions,options.registry); + if(previous.loading)validateLoadingCompletion(previous.acknowledged,previous.versions,previous.loading,loaded.document,loaded.versions,options.registry,true);else validateCanonicalRead(previous.acknowledged,previous.versions,loaded.document,loaded.versions,options.registry); const acknowledged=createDocument(loaded.document.projectId,[...loaded.document.canvases.values()],[...loaded.document.elements.values()],loaded.document.tombstones); const canonical=Object.freeze({document:acknowledged,versions:readMap(loaded.versions)});staged.assertCanonical(canonical); let working:Document|undefined,conflict:RecoverySession['conflict']; try{working=replayPending(acknowledged,previous.ledger,options.registry);}catch(error){const rejected=reject(error);conflict=freeze({code:rejected.code,targets:rejected.targets});} let committed:ConflictPlan|'refresh'|undefined,committedIds:readonly Id[]=[]; const plans=new WeakSet(); - const finish=async()=>{await persist();assertCurrent();await scheduler.resume();assertCurrent();scheduler.notify(committedIds);runTracker.notify(staged.runIds);active.release();return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:committedIds};}; + const finish=async()=>{if(loadingRelease&&!editor.getSnapshot().runtime.loading)loadingCompletionRevision=editor.getSnapshot().rootRevision;await persist();assertCurrent();await scheduler.resume();assertCurrent();scheduler.notify(committedIds);runTracker.notify(staged.runIds);active.release();return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:committedIds};}; active.cancel=async()=>{if(recovery!==active)return;if(committed){await finish();return;}active.release();}; return Object.freeze({baseRootRevision:root.rootRevision,canonical,acknowledgedRequestIds:staged.requestIds, pendingOperationIds:Object.freeze([...previous.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)), @@ -729,7 +753,7 @@ export async function openProjectEngine(options: EngineOptions) { if(intent.operationType==='run.apply'){const run=previous.runs.get(intent.action.runId);if(!run||run.blocked||run.status!=='conflict'||!runContainsTarget(run,intent.action.target))return{status:'rejected',code:'output_not_applicable',targets:[intent.action.target.id]};}} const recoveries=new Map(previous.recoveries),resourcePins=new Map(previous.resourcePins),conflicts=new Map(previous.conflicts);recoveries.set(plan.id,plan.audit);resourcePins.set(`recovery:${plan.id}`,plan.resourceIds);conflicts.delete('refresh'); for(const decision of plan.audit.decisions){const requestId=previous.ledger.operations.get(decision.operationId)?.requestId;if(requestId)conflicts.delete(requestId);} - const result=editor.reconcile(plan.document,Object.freeze({...previous,ledger:plan.ledger,acknowledged,versions:canonical.versions,recoveries:readMap(recoveries),resourcePins:readMap(resourcePins),conflicts:readMap(conflicts)}),root.rootRevision,plan); + const result=editor.reconcile(plan.document,Object.freeze({...previous,ledger:plan.ledger,loading:undefined,acknowledged,versions:canonical.versions,recoveries:readMap(recoveries),resourcePins:readMap(resourcePins),conflicts:readMap(conflicts)}),root.rootRevision,plan); if(result.status==='rejected')return result;committed=plan;committedIds=plan.operationIds; } return finish(); @@ -740,7 +764,7 @@ export async function openProjectEngine(options: EngineOptions) { if(conflict)return{status:'rejected',...conflict}; if(!committed){ const conflicts=new Map(previous.conflicts);conflicts.delete('refresh'); - const result=editor.reconcile(working!,Object.freeze({...previous,acknowledged,versions:canonical.versions,conflicts:readMap(conflicts)}),root.rootRevision); + const result=editor.reconcile(working!,Object.freeze({...previous,loading:undefined,acknowledged,versions:canonical.versions,conflicts:readMap(conflicts)}),root.rootRevision); if(result.status==='rejected')return result; committed='refresh'; } @@ -801,6 +825,7 @@ export async function openProjectEngine(options: EngineOptions) { }catch(error){return reject(error);} }, refresh(document: Document, versions: ReadonlyMap): Result { + if(editor.getSnapshot().runtime.loading)return completeLoading(document,versions,{fresh:true}); const root = editor.getSnapshot(), previous = root.runtime; try { assertWritable(); @@ -813,7 +838,7 @@ export async function openProjectEngine(options: EngineOptions) { return editor.reconcile(working, Object.freeze({ ...previous, acknowledged, versions: readMap(versions), conflicts: readMap(conflicts) }), root.rootRevision); } catch (error) { const result = reject(error); - if (!['refresh_waiting_for_receipts','engine_closing','recovery_in_progress'].includes(result.code)) editor.updateRuntime(previous => { const conflicts = new Map(previous.conflicts); conflicts.set('refresh', result.code); return Object.freeze({ ...previous, conflicts: readMap(conflicts) }); }); + if (!['refresh_waiting_for_receipts','engine_closing','recovery_in_progress','identity_changed'].includes(result.code)) editor.updateRuntime(previous => { const conflicts = new Map(previous.conflicts); conflicts.set('refresh', result.code); return Object.freeze({ ...previous, conflicts: readMap(conflicts) }); }); return result; } }, @@ -884,7 +909,7 @@ 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();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(); }, + retry: async () => { if(recovery)await recovery.cancel();assertWritable(false);await persist();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; diff --git a/src/lib/canvas-runtime/index.ts b/src/lib/canvas-runtime/index.ts index 61bab8c..84de54c 100644 --- a/src/lib/canvas-runtime/index.ts +++ b/src/lib/canvas-runtime/index.ts @@ -25,3 +25,5 @@ export type { ImageDecoderPort, ImageDimensions, ImageDecodeState } from './imag export {createModelPreview} from './model-preview.ts'; export type {ModelPreview} from './model-preview.ts'; + +export type {LoadingState} from './loading.ts'; diff --git a/src/lib/canvas-runtime/loading.ts b/src/lib/canvas-runtime/loading.ts new file mode 100644 index 0000000..73add77 --- /dev/null +++ b/src/lib/canvas-runtime/loading.ts @@ -0,0 +1,59 @@ +import type {Availability,Change,Command,Document,Id,Registry} from '../canvas-core/types.ts'; +import {validateDocument} from '../canvas-core/document.ts'; +import {equal,fault} from '../canvas-core/values.ts'; +import type {RuntimeState} from './contracts.ts'; + +/** Durable marker for a verified read prefix. No network write is admitted while + * this exists. The token binds prefix extensions, not a resumable HTTP cursor. */ +export type LoadingState={readonly version:1;readonly sourceToken:string;readonly declaredCanvasIds:readonly Id[];readonly receivedElements:number;readonly totalElements:number}; +export function validateLoadingState(value:LoadingState,document:Document,versions:ReadonlyMap):void{ + if(!value||value.version!==1)throw fault('loading_interpreter_unavailable'); + for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]); + if(typeof value.sourceToken!=='string'||!value.sourceToken||value.sourceToken.length>256||!Array.isArray(value.declaredCanvasIds)||!value.declaredCanvasIds.length||value.declaredCanvasIds.some(id=>typeof id!=='string'||!id||!versions.has(id))||new Set(value.declaredCanvasIds).size!==value.declaredCanvasIds.length||!value.declaredCanvasIds.includes(document.projectId)||[...document.canvases.keys()].some(id=>!value.declaredCanvasIds.includes(id))||![value.receivedElements,value.totalElements].every(value=>Number.isSafeInteger(value)&&value>=0)||value.receivedElements>value.totalElements||document.elements.size>value.receivedElements)throw fault('invalid_loading_state'); +} +const denied:Availability=Object.freeze({enabled:false,code:'model_loading',targets:[]}); +function dataKeyAllowed(componentId:Id,key:string,registry:Registry):boolean{ + const definition=registry.components.get(componentId); + return !!definition?.copy&&!['src','assetId','presentation','ports','ai','taskId','runId'].includes(key)&&!definition.resourceFields?.includes(key)&&!definition.copy.references.some(ref=>ref.path[0]===key||ref.path[0]==='*'); +} +export function loadingCommand(command:Command,document:Document,registry:Registry):Availability{ + if(command.type!=='element.data'&&command.type!=='element.geometry')return denied; + const element=document.elements.get(command.target.id);if(!element||registry.resolve(element)!=='known')return denied; + if(command.type==='element.geometry')return element.structure==='node'?{enabled:true}:denied; + return Object.keys(command.patch).every(key=>dataKeyAllowed(element.componentId,key,registry))?{enabled:true}:denied; +} +/** Check actual changes too: a feature handler cannot evade command admission by + * modifying a protected reference field as a side effect of an allowed edit. */ +export function assertLoadingChanges(changes:readonly Change[],document:Document,registry:Registry):void{ + for(const change of changes){ + if(change.kind!=='field')throw fault('model_loading'); + const element=document.elements.get(change.target.id); + if(!element||element.incarnation!==change.target.incarnation||registry.resolve(element)!=='known'||!(change.field==='geometry'&&element.structure==='node'||change.field.startsWith('data.')&&dataKeyAllowed(element.componentId,change.field.slice(5),registry)))throw fault('model_loading',[change.target.id]); + } +} +export function validateLoadingRuntime(runtime:RuntimeState,registry:Registry):void{ + if(!runtime.loading)return; + validateLoadingState(runtime.loading,runtime.acknowledged,runtime.versions); + if(runtime.ledger.requests.size||runtime.resources.size||runtime.runs.size||runtime.imports?.size||runtime.orphanFiles?.size||runtime.recoveries.size)throw fault('invalid_loading_state'); + for(const record of runtime.ledger.operations.values()){ + if(record.intent.operationType!=='document.change'||record.requestId||record.resolution||!['pending','blocked'].includes(record.phase))throw fault('invalid_loading_state'); + assertLoadingChanges(record.intent.changes,runtime.acknowledged,registry); + } +} +export function validatePrefixExtension(previous:Document,previousVersions:ReadonlyMap,state:LoadingState,next:Document,versions:ReadonlyMap,nextState:LoadingState,registry:Registry):void{ + validateDocument(next,registry);validateLoadingState(nextState,next,versions); + if(next.projectId!==previous.projectId||state.sourceToken!==nextState.sourceToken||state.totalElements!==nextState.totalElements||nextState.receivedElements!equal(record,next.canvases.get(id)))||[...previous.elements].some(([id,record])=>!equal(record,next.elements.get(id))))throw fault('loading_source_mismatch'); +} +/** Full fresh reads may include new records in an unchanged-version scope. Known + * records still cannot change without an owner version advancing. */ +export function validateLoadingCompletion(previous:Document,previousVersions:ReadonlyMap,state:LoadingState,next:Document,versions:ReadonlyMap,registry:Registry,fresh=false,sourceToken?:string):void{ + validateDocument(next,registry);if(previous.projectId!==next.projectId)throw fault('project_mismatch'); + for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]); + for(const id of next.canvases.keys())if(!versions.has(id))throw fault('missing_server_version',[id]); + for(const [id,version]of previousVersions){if((versions.get(id)??-1)!next.canvases.has(id))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))))throw fault('incomplete_loading_model'); +} diff --git a/src/lib/canvas-runtime/recovery-encoding.ts b/src/lib/canvas-runtime/recovery-encoding.ts index 2274448..301bc8c 100644 --- a/src/lib/canvas-runtime/recovery-encoding.ts +++ b/src/lib/canvas-runtime/recovery-encoding.ts @@ -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|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'); +export function sealRecoveryImage(formatVersion:1|2|3|4|5|6|7,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { + if(![1,2,3,4,5,6,7].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);} diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index fd8d432..9469467 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -1,3 +1,4 @@ +import {validateLoadingRuntime,type LoadingState} from './loading.ts'; import {validateOrphanFile,type OrphanFile} from './orphan-files.ts'; import {importFiles} from './import-file-retention.ts'; import {resourceRetentionPins} from './resource-retention.ts'; @@ -25,9 +26,9 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage { document: documentValue(snapshot.document), history: { undo: snapshot.history.undo, redo: snapshot.history.redo, entries: [...snapshot.history.entries] }, 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] } }, + runtime: { ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(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.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); + return sealRecoveryImage(snapshot.runtime.loading?7:[...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; function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; } @@ -91,11 +92,13 @@ function readHistory(history:ObjectValue,operations:ReadonlyMap 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); + if(runtime.loading!==undefined&&image.formatVersion!==7)throw fault('unsupported_recovery_version'); + if(image.formatVersion===7&&runtime.loading===undefined)throw fault('invalid_loading_state'); 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'); @@ -240,11 +243,12 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn 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) }), + runtime: Object.freeze({ ...(runtime.loading!==undefined?{loading:freeze(runtime.loading as LoadingState)}:{}), orphanFiles, imports, recoveries, runs, resources, resourcePins, ledger: restoredLedger, acknowledged, versions, receipts, conflicts: table(runtime.conflicts, string) }), }); if([...resources.values()].some(resource=>resource.retirement?.purgedAt!==undefined&&!resource.stored)){ const pins=resourceRetentionPins(snapshot,registry);for(const [id,resource]of resources)if(resource.retirement?.purgedAt!==undefined&&!resource.stored&&pins.pins.has(id))throw fault('corrupt_recovery_image'); } if([...orphanFiles.values()].some(file=>file.retirement?.purgedAt!==undefined)){const pins=resourceRetentionPins(snapshot,registry);for(const [id,file]of orphanFiles)if(file.retirement?.purgedAt!==undefined&&pins.pins.has(id))throw fault('corrupt_recovery_image');} + validateLoadingRuntime(snapshot.runtime,registry); return snapshot; } diff --git a/src/lib/canvas-runtime/storage-port.ts b/src/lib/canvas-runtime/storage-port.ts index cd3d6b0..c2f6d77 100644 --- a/src/lib/canvas-runtime/storage-port.ts +++ b/src/lib/canvas-runtime/storage-port.ts @@ -2,7 +2,7 @@ import type { Json } from '../canvas-core/types.ts'; /** Portable durable-store contract. Browser database types belong to the adapter. */ export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string }; -export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json }; +export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7; 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 }; export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string}; diff --git a/src/lib/canvas-runtime/storage.ts b/src/lib/canvas-runtime/storage.ts index 8252a57..c1196e5 100644 --- a/src/lib/canvas-runtime/storage.ts +++ b/src/lib/canvas-runtime/storage.ts @@ -30,7 +30,7 @@ function purgedFileIds(image:RecoveryImage):Set{ return result; } function validateImage(image: RecoveryImage) { - if (![1,2,3,4,5,6].includes(image.formatVersion)) throw fault('unsupported_recovery_version'); + if (![1,2,3,4,5,6,7].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 = (request: IDBRequest) => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); @@ -160,7 +160,8 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: if(!('image' in candidate)&&(candidate.baseRevision!==previous||candidate.baseChecksum!==saved?.checksum))throw fault('recovery_delta_base_mismatch'); for(const file of files){if(!file.id||!(file.bytes instanceof Blob))throw fault('invalid_staged_file');if(await requestValue(transaction.objectStore('files').getKey([key,file.id])))throw fault('resource_identity_conflict',[file.id]);transaction.objectStore('files').put(file.bytes,[key,file.id]);} for(const id of deleteFiles)transaction.objectStore('files').delete([key,id]); - if(previous===undefined||image.rootRevision-(metadata.checkpointRevision??0)>=checkpointEvery){ + // A complete cut must not depend on a v7 partial checkpoint in an older reader. + if(previous===undefined||(base?.image.formatVersion===7&&image.formatVersion!==7)||image.rootRevision-(metadata.checkpointRevision??0)>=checkpointEvery){ transaction.objectStore('checkpoints').put(stored,[key,image.rootRevision]);if(metadata.checkpointRevision!==undefined)transaction.objectStore('checkpoints').delete([key,metadata.checkpointRevision]);transaction.objectStore('journal').delete(IDBKeyRange.bound([key,0],[key,image.rootRevision]));metadata.checkpointRevision=image.rootRevision; }else transaction.objectStore('journal').put(candidate,[key,image.rootRevision]); transaction.objectStore('metadata').put({...metadata,throughRevision:image.rootRevision},key);return{durableRootRevision:image.rootRevision}; diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 3715601..a601238 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -679,6 +679,25 @@ async function engineRecoveryCase(name, assert) { } finally { await engine?.close(); storage.close(); indexedDB.deleteDatabase(`${name}-engine`); } } +async function loadingRecoveryCase(name,assert){ + const root=crypto.randomUUID(),scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:root},registry=createRegistry(builtinComponents()),node=id=>({id,incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:120,height:80,rotation:0}}),a=node('A'),b=node('B'),canvases=[{id:root,parent:null}],prefix=createDocument(root,canvases,[a]),full=createDocument(root,canvases,[a,b]),versions=new Map([[root,1]]),loading={version:1,sourceToken:'browser-captured-read',declaredCanvasIds:[root],receivedElements:1,totalElements:2}; + const adapter=createBrowserStorage({name:`${name}-loading`,checkpointEvery:3});let ownerForTest;const storage={...adapter,async claim(scope){ownerForTest=await adapter.claim(scope);return ownerForTest;}};const calls=[],receipts=new Map();let server=full,version=1,engine; + const model=(complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases,elements:[...server.elements.values()],tombstones:{},versions:{[root]:version},removedCanvasIds:[]}); + const transport=async input=>{if(input.method==='GET')return{status:200,body:{model:model()}};calls.push(input);const body=JSON.parse(input.body);assert(body.expected_versions[root]===version,'loading writer used a stale version');server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));version++;const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)};receipts.set(input.requestId,receipt);return{status:200,body:receipt};}; + const persistence=createFoundryPersistence({registry,workspaceId:scope.workspaceId,rootId:root,transport}),open=()=>openProjectEngine({scope,registry,document:prefix,versions,loading,storage,persistence,createId:()=>crypto.randomUUID()}); + try{engine=await open();const target={id:'A',incarnation:1};assert(engine.editor.dispatch({type:'element.data',target,patch:{text:'local before tail'}}).status==='applied','loading field rejected');await engine.settle();assert(calls.length===0&&engine.getSnapshot().runtime.ledger.requests.size===0,'loading prefix froze or sent a request');await engine.close(); + const owner=await storage.claim(scope),image=await storage.recover(owner);assert(image.formatVersion===7,'loading state did not require recovery v7');decodeRecovery(image,registry);await owner.release();engine=await open();assert(engine.getSnapshot().document.elements.get('A').data.text==='local before tail','loading edit lost after IndexedDB reopen');assert(engine.editor.history.undo().status==='applied'&&engine.editor.history.redo().status==='applied','loading history did not reopen');await engine.settle();assert(calls.length===0,'history traversal escaped the loading hold'); + const before=engine.getDurability().durableRootRevision,nativePut=IDBObjectStore.prototype.put;let injected=false,failure; + IDBObjectStore.prototype.put=function(value,key){const request=nativePut.call(this,value,key);if(!injected&&this.name==='checkpoints'&&value?.image?.formatVersion!==7){injected=true;throw Error('loading_full_cut_abort');}return request;}; + try{await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.put=nativePut;} + assert(injected&&failure==='loading_full_cut_abort','completion did not reach forced checkpoint rollback');const partial=await adapter.recover(ownerForTest);assert(partial.rootRevision===before&&partial.formatVersion===7&&calls.length===0,'aborted full checkpoint released loading writes or lost the partial cut'); + await engine.persist();await engine.settle(); +assert(!engine.loading.getState()&&server.elements.get('A').data.text==='local before tail'&&engine.getSnapshot().document.elements.size===2,'full tail overwrote the local prefix');await engine.close();engine=await open();await engine.settle();assert(!engine.loading.getState()&&engine.getSnapshot().document.elements.size===2,'completed recovery reopened as partial'); + const database=await new Promise((resolve,reject)=>{const request=indexedDB.open(`${name}-loading`,2);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);}); + try{const transaction=database.transaction(['checkpoints','journal'],'readonly'),read=store=>new Promise((resolve,reject)=>{const request=transaction.objectStore(store).getAll();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const cuts=(await Promise.all([read('checkpoints'),read('journal')])).flat();assert(cuts.every(cut=>(cut.image?.formatVersion??cut.header?.formatVersion)<7),'completed storage still requires a v7 journal base');}finally{database.close();} + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-loading`);} +} + export async function run() { const results = [], name = `headless-test-${crypto.randomUUID()}`; const scope = { accountId: 'test-account', workspaceId: 'test-workspace', projectId: 'test-project' }; @@ -700,7 +719,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: 7 } }), 'unsupported_recovery_version'); + await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 8 } }), '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. @@ -749,6 +768,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 loadingRecoveryCase(name,assert); + results.push('recovery v7 preserves loaded-field edits/history in IndexedDB; an injected full-checkpoint failure retains the partial cut and zero writes, then retry removes v7 journal dependencies before releasing requests'); await scopedNetworkLoaderCase(assert); results.push('scoped loader shares one real streamed fetch, exposes the first chunk before tail release, keeps the other subscriber alive after cancellation and preserves partial data after a malformed tail'); await defaultImageSizeCase(name, assert); diff --git a/tests/headless/loading-engine.test.mjs b/tests/headless/loading-engine.test.mjs new file mode 100644 index 0000000..f796c55 --- /dev/null +++ b/tests/headless/loading-engine.test.mjs @@ -0,0 +1,101 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {createDocument,applyChanges,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts'; +import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts'; +import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts'; +import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts'; +const registry=createRegistry(builtinComponents()),root='10000000-0000-4000-8000-000000000001',child='10000000-0000-4000-8000-000000000002'; +const scope={accountId:'10000000-0000-4000-8000-000000000003',workspaceId:'10000000-0000-4000-8000-000000000004',projectId:root}; +const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:100,height:80,rotation:0}}); +const a=node('A'),b=node('B'),frame={...node('F'),componentId:'frame'},c=node('C',child),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'C'},path:null}; +const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],prefix=createDocument(root,[canvases[0]],[a]),full=createDocument(root,canvases,[a,b,frame,c,edge]); +const versions=new Map([[root,1],[child,1]]),state={version:1,sourceToken:'captured-read',declaredCanvasIds:[root,child],receivedElements:1,totalElements:5},target={id:'A',incarnation:1}; +function fixture(){ + let image=null,leader,server=full;const serverVersions=new Map(versions),calls=[],receipts=new Map(),errors=[]; + const storage={broken:false,saved:[],async claim(scope){assert.equal(leader,undefined);const held={scope,fence:1,active:true,async release(){leader=undefined;held.active=false;}};leader=held;return held;},async recover(){return structuredClone(image);},async commit(held,batch){assert.strictEqual(held,leader);if(storage.broken)throw Error('quota');image=structuredClone(batch.image);storage.saved.push(image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){},image:()=>structuredClone(image)}; + const model=(participants=[...serverVersions.keys()],complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...server.canvases.values()].filter(item=>participants.includes(item.id)),elements:[...server.elements.values()].filter(item=>participants.includes(item.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,serverVersions.get(id)])),removedCanvasIds:[]}); + const transport=async input=>{ + if(input.method==='GET')return input.path.endsWith('/model')?{status:200,body:{model:model()}}:{status:200,body:receipts.get(input.path.split('/').at(-1))}; + calls.push(input);const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert.equal(body.expected_versions[id],serverVersions.get(id)); + if(!receipts.has(input.requestId)){server=applyChanges(server,body.operations.flatMap(item=>item.changes));for(const id of participants)serverVersions.set(id,serverVersions.get(id)+1);receipts.set(input.requestId,{protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(item=>item.id),model:model(participants,false)});} + return{status:200,body:receipts.get(input.requestId)}; + }; + const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:root,registry,transport}); + return{storage,persistence,calls,errors,server:()=>server,advance(changes){server=applyChanges(server,changes);serverVersions.set(root,serverVersions.get(root)+1);},currentVersions:()=>new Map(serverVersions),open:extra=>openProjectEngine({scope,registry,document:prefix,versions,loading:state,storage,persistence,createId:randomUUID,onError:error=>errors.push(error),...extra})}; +} + +test('loaded fields edit and undo/redo durably while no request freezes; full source adds tail and sends each edit once',async()=>{ + const f=fixture();let engine=await f.open();try{ + assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}}).status,'applied');await engine.settle(); + assert.equal(f.calls.length,0);assert.equal(engine.getSnapshot().runtime.ledger.requests.size,0);assert.equal(f.storage.image().formatVersion,7);decodeRecovery(f.storage.image(),registry); + assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();const history=engine.getSnapshot().history; + await engine.close();engine=await f.open();await engine.settle();assert.equal(f.calls.length,0);assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'local');assert.equal(engine.getSnapshot().document.elements.get('A').geometry.x,25); + const next=createDocument(root,[canvases[0]],[a,b]);assert.equal(engine.loading.accept(next,versions,{...state,receivedElements:2}).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'local');assert.equal(engine.getSnapshot().document.elements.size,2);assert.deepEqual(engine.getSnapshot().history.undo,history.undo); + assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(engine.getSnapshot().document.elements.size,5);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.server().elements.get('A').geometry.x,25);assert.ok(f.calls.length>0);assert.equal(engine.getSnapshot().history.undo.length,2);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));assert.equal(f.errors.length,0); + }finally{await engine.close();} +}); +test('incomplete scopes block structure/resources/references and untrusted marker removal without changing the root',async()=>{ + const f=fixture(),engine=await f.open();try{ + for(const command of [{type:'element.remove',target},{type:'element.create',element:node('new')},{type:'element.move',target,canvasId:child},{type:'element.data',target,patch:{assetId:'asset'}},{type:'element.data',target,patch:{presentation:{scale:2}}}]){const before=engine.getSnapshot();assert.equal(engine.editor.dispatch(command).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);} + const before=engine.getSnapshot();assert.equal(engine.editor.updateRuntime(previous=>({...previous,loading:undefined})).code,'loading_state_owned');assert.strictEqual(engine.getSnapshot(),before); + assert.throws(()=>engine.stageFiles([{name:'x',bytes:new Blob(['x'])}],()=>{}),{code:'model_loading'});await assert.rejects(engine.imports.duplicate([target]),{code:'model_loading'});assert.equal(f.calls.length,0); + }finally{await engine.close();} +}); +test('full baseline must be durable before the hold releases; quota retry never sends from the partial cut',async()=>{ + const f=fixture(),engine=await f.open();try{ + engine.editor.dispatch({type:'element.data',target,patch:{title:'saved edit'}});await engine.settle();f.storage.broken=true; + await assert.rejects(engine.loading.complete(full,versions,{sourceToken:state.sourceToken}),/quota/);assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,7);assert.ok(engine.getSnapshot().runtime.ledger.requests.size===0); + f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(f.server().elements.get('A').data.title,'saved edit');assert.ok(f.calls.length>0);assert.notEqual(f.storage.image().formatVersion,7); + }finally{f.storage.broken=false;await engine.close();} +}); +test('late or changed prefix is rejected; a fresh full read preserves local edits and same-version new records',async()=>{ + const f=fixture(),engine=await f.open();try{ + engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const before=engine.getSnapshot(); + assert.equal(engine.loading.accept(prefix,versions,{...state,sourceToken:'another'}).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before); + const changed=createDocument(root,[canvases[0]],[{...a,data:{...a.data,title:'remote'}}]);assert.equal(engine.loading.accept(changed,versions,state).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before); + assert.equal((await engine.loading.complete(full,versions,{fresh:true})).status,'applied');await engine.settle();const done=engine.getSnapshot();assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.strictEqual(engine.getSnapshot(),done); + }finally{await engine.close();} +}); +test('v7 cannot lose its marker or downgrade, and recovery retains partial edit/history without requests',async()=>{ + const f=fixture(),engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const image=f.storage.image();assert.equal(decodeRecovery(image,registry).runtime.loading.sourceToken,state.sourceToken); + for(const formatVersion of [1,2,3,4,5,6])assert.throws(()=>decodeRecovery({...image,formatVersion},registry),{code:'unsupported_recovery_version'}); + const missing=structuredClone(image);delete missing.payload.runtime.loading;assert.throws(()=>decodeRecovery(missing,registry),{code:'invalid_loading_state'}); + }finally{await engine.close();} +}); + +test('a fresh conflicting completion keeps the original partial edit and resolves through the existing reviewed recovery path',async()=>{ + const f=fixture(),engine=await f.open();try{ + const edit=engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle(); + f.advance([{kind:'field',target,field:'data.text',before:'original',after:'remote',beforePresent:true,afterPresent:true}]); + assert.equal((await engine.loading.complete(f.server(),f.currentVersions(),{fresh:true})).status,'rejected');assert.ok(engine.loading.getState());assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'local');assert.equal(f.calls.length,0); + const session=await engine.prepareRecovery();assert.ok(session.conflict);const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));assert.equal((await session.resolve(plan)).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.errors.length,0); + }finally{await engine.close();} +}); +test('reopening a partial image with a complete fresh model finishes through refresh without replacing history',async()=>{ + const f=fixture();let engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{title:'retained'}});await engine.settle();const history=engine.getSnapshot().history;await engine.close();engine=await f.open({document:full,loading:undefined});assert.ok(engine.loading.getState());assert.equal(engine.refresh(full,versions).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'retained');assert.deepEqual(engine.getSnapshot().history.undo,history.undo);}finally{await engine.close();} +}); + +test('permitted command cannot hide a reference write inside a feature handler',async()=>{ + const {defineComponent,defineFeature}=await import('../../src/lib/canvas-core/index.ts'); + const custom=defineComponent({id:'unsafe-handler',dataVersion:1,structure:'node',validate:()=>true,resourceFields:[],copy:{references:[{path:['link'],target:'element',external:'preserve'}],omit:[]},features:[defineFeature('side-effect',{'content.edit':({element})=>[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.link',before:'old',after:'new',beforePresent:true,afterPresent:true}]})]}); + const customRegistry=createRegistry([...builtinComponents(),custom]),record={...a,componentId:custom.id,data:{title:'A',link:'old'}},document=createDocument(root,[canvases[0]],[record]),f=fixture(),engine=await f.open({registry:customRegistry,document}); + try{const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'allowed title'}}).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();} +}); +test('future loading interpreters, changed capture counts and forged frozen requests fail closed on recovery',async()=>{ + const f=fixture(),engine=await f.open();try{await engine.persist();const source=f.storage.image(); + for(const mutate of [image=>image.payload.runtime.loading.version=2,image=>image.payload.runtime.loading.receivedElements=99,image=>image.payload.runtime.loading.declaredCanvasIds=[],image=>image.payload.runtime.ledger.requests.push(['fake',{}])]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));} + }finally{await engine.close();} +}); + +test('fresh full read merges unrelated remote fields and geometry undo does not roll them back',async()=>{ + const f=fixture(),engine=await f.open();try{ + engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}});await engine.settle();f.advance([{kind:'field',target,field:'data.title',before:'A',after:'remote title',beforePresent:true,afterPresent:true}]); + assert.equal((await engine.loading.complete(f.server(),f.currentVersions(),{fresh:true})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('A').data.title,'remote title');assert.equal(f.server().elements.get('A').geometry.x,25); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').geometry.x,0);assert.equal(f.server().elements.get('A').data.title,'remote title'); + }finally{await engine.close();} +}); + +test('identity fencing rejects edits, history traversal and late prefix publication without changing the root',async()=>{ + const f=fixture();let current=true;const engine=await f.open({isCurrentIdentity:()=>current});try{engine.editor.dispatch({type:'element.data',target,patch:{text:'saved'}});await engine.settle();current=false;const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'late'}}).code,'identity_changed');assert.equal(engine.editor.history.undo().code,'identity_changed');assert.equal(engine.loading.accept(prefix,versions,state).code,'identity_changed');assert.equal(engine.refresh(full,versions).code,'identity_changed');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();} +}); diff --git a/tests/headless/package.integration.mjs b/tests/headless/package.integration.mjs index fee29e6..24bc0d5 100644 --- a/tests/headless/package.integration.mjs +++ b/tests/headless/package.integration.mjs @@ -40,9 +40,13 @@ try { // No DOM library: browser adapter declarations must not leak into portable ports. await writeFile(join(packageRoot,'consumer-types.mts'),` import {createRegistry,builtinComponents,createDocument} from '@endoleap/headless-canvas/core'; - import {openProjectEngine,type StoragePort,type StorageScope} from '@endoleap/headless-canvas/runtime'; + import {openProjectEngine,type StoragePort,type StorageScope,type LoadingState} from '@endoleap/headless-canvas/runtime'; import {createFoundryPersistence,type ServiceTransport} from '@endoleap/headless-canvas/service'; const registry=createRegistry(builtinComponents()); + export async function completePartial(engine:Awaited>,document:Parameters[0]['document'],versions:ReadonlyMap,state:LoadingState){ + const accepted=engine.loading.accept(document,versions,state); + return accepted.status==='applied'?engine.loading.complete(document,versions,{sourceToken:state.sourceToken}):accepted; + } export function open(scope:StorageScope,storage:StoragePort,transport:ServiceTransport){ return openProjectEngine({scope,storage,registry,document:createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]),versions:new Map([[scope.projectId,1]]),persistence:createFoundryPersistence({registry,transport,rootId:scope.projectId,workspaceId:scope.workspaceId})}); } diff --git a/tests/headless/recovery-encoding.test.mjs b/tests/headless/recovery-encoding.test.mjs index 005ad35..e1f247c 100644 --- a/tests/headless/recovery-encoding.test.mjs +++ b/tests/headless/recovery-encoding.test.mjs @@ -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.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.throws(()=>sealRecoveryImage(7,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(8,1,1,{}),/corrupt_recovery_image/); });