Admit durable scoped image uploads during partial model loading

This commit is contained in:
2026-11-09 14:35:41 +08:00
parent 5b35519562
commit 3a71c9ac57
21 changed files with 191 additions and 61 deletions
+29 -1
View File
@@ -150,5 +150,33 @@ Pause/reopen/resume retains source bytes and identities. Cancellation keeps alre
published content and does not restart; undo remains independent of the source
parser. All content sends through the normal durability and frozen-request barriers.
Full-source completion removes the loading marker and read baseline without
replacing job/history state. New image uploads and generation remain gated while
replacing job/history state. In version 5, new image uploads and generation remain gated while
loading; loaded shared resource references obey the existing structural rules.
Loading version 6 (recovery format 12) admits local images in complete owners.
`engine.canStageFiles(canvasId)` supplies UI availability; `stageFiles` rechecks
actual transaction changes and policy. Existing field/geometry and import/copy
contracts continue to apply, including copying a pending image by the same
ResourceId rather than starting another upload.
`ResourceRecord.loadingAdmission` records `{version:1, sourceToken, sourceURL,
canvasIds}`. The resource's original upload OperationId precedes its first document
use. Validation checks that first use's exact owner set, complete captured/born
scopes, and the adapter's URL interpretation on reopening. New resource references
are admitted from these proved identities and the current staging transaction;
arbitrary resource IDs/URLs remain rejected. The proof and file bytes use the
existing project recovery store, not a second queue or workspace-wide AssetDepot.
Working content, history, upload intent and Blob bytes retain their existing atomic
durable cut. An upload has no canvas participants, so its own admission does not
claim a fake canvas writer; it still waits for the root/file/request barriers.
Document requests depend on stored upload results and keep normal owner/version
claims. Upload receipt validation does not advance document versions. A lost ACK
reopens the same frozen key/body and queries it before another document send.
Whole-source completion preserves pending/stored resources and history. Default
image sizing and display handles reuse the existing decoder/cache lifecycle.
Undo/redo, source failure, quota retry and late chunks do not re-upload bytes or
restore deleted incarnations. Earlier loading/recovery versions retain their old
restrictions; future format 13 is rejected. Generation, cross-project durable depot
sharing and broader lifecycle/acceptance work remain separate unfinished goals.
@@ -259,10 +259,10 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={view.tool === 'select'} onClick={() => engine.sessions.tool(viewId, 'select')} /><IconButton icon={Hand} label="抓手工具(H)" aria-pressed={view.tool === 'pan'} onClick={() => engine.sessions.tool(viewId, 'pan')} />
<IconButton icon={Ratio} label="按图片比例调整" disabled={!!loading||!canFitImage} onClick={()=>{if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} />
<IconButton icon={Sparkles} label="AI 创作" disabled={!!loading||!aiElement} aria-pressed={showAI} onClick={() => setShowAI(value => !value)} />
<IconButton icon={Type} label="添加纯文本" disabled={!canCreate} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!canCreate} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!!loading} onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" disabled={!canCreate} onClick={() => add('frame')} />
<IconButton icon={Type} label="添加纯文本" disabled={!canCreate} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!canCreate} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" disabled={!canCreate} onClick={() => add('frame')} />
</div>
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
{(!loading||loading.version===5)&&<HeadlessImports engine={engine} canvasId={view.canvasId} />}
{(!loading||loading.version>=5)&&<HeadlessImports engine={engine} canvasId={view.canvasId} />}
{showAI && aiElement && <HeadlessAIPanel key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
{showRecovery && <HeadlessRecoveryDialog engine={engine} close={() => setShowRecovery(false)} />}
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
+3
View File
@@ -68,6 +68,9 @@ export const errorText = (error: unknown) => {
loading_reference_incomplete: "相关引用尚未完整加载,暂时无法修改。",
invalid_model_coverage: "画布的加载范围信息不一致,请重新加载。",
loading_waiting_for_receipts: "正在确认已发送的修改,请稍后重试。",
invalid_loading_asset: "图片任务的加载范围记录不一致,已保留本地内容并停止同步。",
loading_asset_interpreter_unavailable: "此图片任务需要更新版本后才能恢复。",
asset_source_changed: "图片资源的地址规则发生变化,请使用原配置恢复后重试。",
invalid_model_topology: "画布的分区关系不一致,请重新加载。",
loading_scope_incomplete: "所需分区或上级引用尚未完整加载,请稍后重试。",
invalid_loading_import: "导入任务的加载范围记录不完整,任务已停止。",
+14 -7
View File
@@ -1,3 +1,4 @@
import {loadingAssetURLs} from './loading-assets.ts';
import {loadingImportAdmission,validateLoadingCopySource} from './loading-import.ts';
import {loadingCanSend,mergeLoadingRead,loadingQueryCoverage,assertLoadingChanges,loadingCommand,validateLoadingRuntime,validatePrefixExtension,validateLoadingCompletion,type LoadingState} from './loading.ts';
import {validImageDimensions,type ImageDecoderPort} from './image-decoder.ts';
@@ -99,10 +100,13 @@ export async function openProjectEngine(options: EngineOptions) {
let recovery: { controller: AbortController; release(): void; cancel():Promise<void> } | undefined;
const admissionPins=(snapshot:EngineSnapshot)=>{const pins=new Map<Id,readonly Id[]>();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 = (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'); };
function assetURLs(){const root=editor.getSnapshot(),urls=loadingAssetURLs(root.runtime);return staged.length?new Map([...urls,...staged.map(resource=>[resource.id,options.assets!.sourceURL(resource.id)] as const)]):urls;}
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,...(openingLoading.version>=3?{loadingRead:Object.freeze({document:createDocument(options.document.projectId,[...options.document.canvases.values()],[...options.document.elements.values()],options.document.tombstones),versions:openingVersions})}:{})}:{}), ledger: createLedger().getSnapshot(), receipts: readMap(new Map()), conflicts: readMap(new Map()), resources: readMap(new Map()), resourcePins: readMap(new Map()) });
validateLoadingRuntime(initial,options.registry);
if(initial.loading?.version===6&&options.assets)for(const resource of initial.resources.values())if(resource.loadingAdmission?.sourceURL!==options.assets.sourceURL(resource.id))throw fault('asset_source_changed',[resource.id]);
const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit,queryBudget:options.queryBudget,queryCoverage:loadingQueryCoverage,
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,editor.getSnapshot().runtime.loading,editor.getSnapshot().runtime.loadingRead?.document??editor.getSnapshot().runtime.acknowledged,editor.getSnapshot().runtime.loadingRead);if(!allowed.enabled)return allowed;}return options.policy?.(command, document) ?? { 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,editor.getSnapshot().runtime.loading,editor.getSnapshot().runtime.loadingRead?.document??editor.getSnapshot().runtime.acknowledged,editor.getSnapshot().runtime.loadingRead,assetURLs());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&&(editor.getSnapshot().runtime.loading?.version??0)<4){try{assertLoadingChanges(entry.changes,editor.getSnapshot().document,options.registry,editor.getSnapshot().runtime.loading,editor.getSnapshot().runtime.loadingRead);}catch(error){return{enabled:false,code:(error as Error).message,targets:[]};}}return options.traversalPolicy?.(entry,direction)??{enabled:true}; },
onListenerError: report,
prepareRuntime:(previous,next)=>{
@@ -117,13 +121,15 @@ 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.loading.version>=4?editor.getSnapshot().document:previous.acknowledged,options.registry,previous.loading,previous.loadingRead);
if(previous.loading)assertLoadingChanges(commit.changes,previous.loading.version>=4?editor.getSnapshot().document:previous.acknowledged,options.registry,previous.loading,previous.loadingRead,assetURLs());
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;
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, stagedDefaultSizes.has(resource.id) ? captureDefaultImageSizing(resource, next, commit, options.registry) : resource);
const canvasIds=[...new Set([...next.document.elements.values()].filter(element=>referencedResources([{kind:'element',id:element.id,before:null,after:element}],options.registry).has(resource.id)).map(element=>element.ownerCanvasId))].sort();
const admitted=previous.loading?.version===6?freeze({...resource,loadingAdmission:{version:1 as const,sourceToken:previous.loading.sourceToken,sourceURL:options.assets!.sourceURL(resource.id),canvasIds}}):resource;
resources.set(resource.id, stagedDefaultSizes.has(resource.id) ? captureDefaultImageSizing(admitted, next, commit, options.registry) : admitted);
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']);
});
@@ -396,7 +402,7 @@ export async function openProjectEngine(options: EngineOptions) {
report(error); return { blocked: true };
}
}
const scheduler = createScheduler({ admit:intent=>{const root=editor.getSnapshot();if(root.runtime.loading&&root.runtime.loading.version<3)return true;if(closing||closed||loadingCompletionRevision!==undefined&&durableRootRevision<loadingCompletionRevision)return false;return loadingCanSend(root.runtime,operationCanvases(intent,root.document),root.document)&&(!root.runtime.loading||durableRootRevision>=root.rootRevision);},getLedger: () => editor.getSnapshot().runtime.ledger, mutate, persist,
const scheduler = createScheduler({ admit:intent=>{const root=editor.getSnapshot();if(root.runtime.loading&&root.runtime.loading.version<3)return true;if(closing||closed||loadingCompletionRevision!==undefined&&durableRootRevision<loadingCompletionRevision)return false;return (root.runtime.loading?.version===6&&intent.operationType==='asset.upload'||loadingCanSend(root.runtime,operationCanvases(intent,root.document),root.document))&&(!root.runtime.loading||durableRootRevision>=root.rootRevision);},getLedger: () => editor.getSnapshot().runtime.ledger, mutate, persist,
createId: options.createId ?? (() => crypto.randomUUID()),
participants: intent => { if(intent.historyEffect)validateCreationSizeEffect(intent.historyEffect);return operationCanvases(intent, editor.getSnapshot().document); },
compile(intent, id) {
@@ -429,7 +435,7 @@ export async function openProjectEngine(options: EngineOptions) {
function completeLoading(document:Document,versions:ReadonlyMap<Id,number>,settings:{fresh?:boolean;sourceToken?:string}={}):Result{
try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active');
if(settings.fresh&&previous.loading.version>=3&&[...previous.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('loading_waiting_for_receipts');
if(settings.fresh&&(previous.loading.version===4||previous.loading.version===5)){const complete=new Set(loadingQueryCoverage(root).completeCanvasIds),capture=previous.loadingRead!;
if(settings.fresh&&((previous.loading.version===4||previous.loading.version===5||previous.loading.version===6))){const complete=new Set(loadingQueryCoverage(root).completeCanvasIds),capture=previous.loadingRead!;
for(const [id,owner]of Object.entries(previous.loading.elementOwners))if(versions.get(owner)===capture.versions.get(owner)&&document.elements.get(id)?.ownerCanvasId!==owner)throw fault('canonical_version_mismatch',[owner]);
for(const element of document.elements.values()){if(versions.get(element.ownerCanvasId)===capture.versions.get(element.ownerCanvasId)&&previous.loading.elementOwners[element.id]!==element.ownerCanvasId)throw fault('canonical_version_mismatch',[element.ownerCanvasId]);if(complete.has(element.ownerCanvasId)&&versions.get(element.ownerCanvasId)===previous.versions.get(element.ownerCanvasId)&&!equal(element,previous.acknowledged.elements.get(element.id)))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);}}
const source=!settings.fresh&&previous.loading.version>=3?previous.loadingRead:undefined;validateLoadingCompletion(source?.document??previous.acknowledged,source?.versions??previous.versions,previous.loading,document,versions,options.registry,settings.fresh,settings.sourceToken);
@@ -589,7 +595,7 @@ export async function openProjectEngine(options: EngineOptions) {
const result=editor.updateRuntime(previous=>{const ledger=createLedger(previous.ledger);ledger.add(freeze(intent),{});return Object.freeze({...previous,ledger:ledger.getSnapshot()});},root.rootRevision);
if(result.status==='rejected')return result;queueMicrotask(()=>{if(!closing&&!closed)scheduler.notify([id]);});return{status:'applied',revision:root.revision,operationIds:[id]};
}
function assertImportWritable(){assertWritable(false);const loading=editor.getSnapshot().runtime.loading;if(loading&&loading.version!==5)throw fault('model_loading');}
function assertImportWritable(){assertWritable(false);const loading=editor.getSnapshot().runtime.loading;if(loading&&loading.version<5)throw fault('model_loading');}
function getImport(id:Id){const job=editor.getSnapshot().runtime.imports?.get(id);if(!job)throw fault('import_missing',[id]);return job;}
function publishImport(job:ImportJob,expectedGeneration:number){
assertImportWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
@@ -864,8 +870,9 @@ export async function openProjectEngine(options: EngineOptions) {
return result;
}
},
canStageFiles(canvasId:Id){try{assertWritable(false);if(!options.assets)throw fault('asset_interpreter_unavailable');const snapshot=editor.getSnapshot();if(!snapshot.document.canvases.has(canvasId))throw fault('canvas_not_loaded',[canvasId]);if(snapshot.runtime.loading&&(snapshot.runtime.loading.version!==6||!loadingQueryCoverage(snapshot).completeCanvasIds.includes(canvasId)))throw fault('loading_scope_incomplete',[canvasId]);const permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}},
stageFiles(files: readonly StagedFile[], callback: (resources: readonly ResourceHandle[], transaction: Transaction) => unknown) {
assertWritable();
assertWritable(false);if(editor.getSnapshot().runtime.loading&&editor.getSnapshot().runtime.loading?.version!==6)throw fault('model_loading');
if (staged.length) throw fault('reentrant_dispatch');
if (!options.assets) throw fault('asset_interpreter_unavailable');
const resources = files.map(file => {
+1 -1
View File
@@ -37,7 +37,7 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
const preview=settings.onPreview?createModelPreview(options.registry):undefined;let seen=0,previewFailed=false;let declaredCanvasIds:readonly Id[]=[];
const onUpdate=(state:import('./loader.ts').LoadSnapshot<CanonicalReadUpdate>)=>{
if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return;
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value){if(chunk.sequence===0)declaredCanvasIds=Object.freeze(chunk.value.canvases.map(canvas=>canvas.id));settings.onPreview?.(Object.freeze({...value,versions:readMap(new Map(Object.entries(chunk.serverVersions))),loading:Object.freeze({...(value.canvasElementCounts?value.elementOwners?{version:5 as const,canvasElementCounts:value.canvasElementCounts,elementOwners:value.elementOwners,sourceCanvases:value.sourceCanvases!}:{version:3 as const,canvasElementCounts:value.canvasElementCounts}:{version:1 as const}),sourceToken:chunk.snapshotToken,declaredCanvasIds,receivedElements:value.receivedElements,totalElements:value.totalElements})}));}}}
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value){if(chunk.sequence===0)declaredCanvasIds=Object.freeze(chunk.value.canvases.map(canvas=>canvas.id));settings.onPreview?.(Object.freeze({...value,versions:readMap(new Map(Object.entries(chunk.serverVersions))),loading:Object.freeze({...(value.canvasElementCounts?value.elementOwners?{version:6 as const,canvasElementCounts:value.canvasElementCounts,elementOwners:value.elementOwners,sourceCanvases:value.sourceCanvases!}:{version:3 as const,canvasElementCounts:value.canvasElementCounts}:{version:1 as const}),sourceToken:chunk.snapshotToken,declaredCanvasIds,receivedElements:value.receivedElements,totalElements:value.totalElements})}));}}}
catch(error){previewFailed=true;try{options.onObserverError?.(error);}catch{}}
};
const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate});
+1 -1
View File
@@ -7,7 +7,7 @@ export { createRuntimeHost } from './host.ts';
export type { RuntimeHost } from './host.ts';
export type * from './contracts.ts';
export type * from './storage-port.ts';
export type { AssetPort, ResourceRecord, ResourceHandle, StagedFile, StoredAsset, DisplayHandle } from './resources.ts';
export type { LoadingAssetAdmission, AssetPort, ResourceRecord, ResourceHandle, StagedFile, StoredAsset, DisplayHandle } from './resources.ts';
export type { RunPort, RunRecord, RunPage, RunEvent, RunStatus, RunModel } from './runs.ts';
export type { FrozenRequest, LedgerSnapshot, OperationRecord, Presence } from './ledger.ts';
export type { Clock, OperationStatus, CompiledRequest } from './scheduler.ts';
+40
View File
@@ -0,0 +1,40 @@
import type {Document,Id,Registry,OperationIntent} from '../canvas-core/types.ts';
import {equal,fault} from '../canvas-core/values.ts';
import type {RuntimeState} from './contracts.ts';
import {structuralRequestScope} from './loading.ts';
import {referencedResources,type LoadingAssetAdmission} from './resources.ts';
/** Proven local upload identities; never infer authorization from an arbitrary URL. */
const namespaces=new WeakMap<object,{proofs:Map<Id,LoadingAssetAdmission>;urls:ReadonlyMap<Id,string>}>();
export function loadingAssetURLs(runtime:RuntimeState):ReadonlyMap<Id,string>{
const source=runtime.loadingRead;if(!source||runtime.loading?.version!==6)return new Map();
const proofs=new Map<Id,LoadingAssetAdmission>();for(const resource of runtime.resources.values())if(resource.loadingAdmission)proofs.set(resource.id,resource.loadingAdmission);
const previous=namespaces.get(source);if(previous&&previous.proofs.size===proofs.size&&[...proofs].every(([id,proof])=>previous.proofs.get(id)===proof))return previous.urls;
const urls=new Map([...proofs].map(([id,proof])=>[id,proof.sourceURL]));namespaces.set(source,{proofs,urls});return urls;
}
export function validateLoadingAssetAdmissions(runtime:RuntimeState,registry:Registry):void{
const loading=runtime.loading;if(loading?.version!==6)return;
for(const resource of runtime.resources.values()){
const proof=resource.loadingAdmission,record=runtime.ledger.operations.get(resource.operationId);
if(!proof||proof.version!==1||proof.sourceToken!==loading.sourceToken||typeof proof.sourceURL!=='string'||!proof.sourceURL||proof.sourceURL.length>8192||!Array.isArray(proof.canvasIds)||!proof.canvasIds.length||new Set(proof.canvasIds).size!==proof.canvasIds.length||proof.canvasIds.some(id=>typeof id!=='string'||!structuralRequestScope(runtime,runtime.loadingRead!,id))||record?.intent.operationType!=='asset.upload'||record.intent.resourceId!==resource.id||resource.stored&&record.resolution!=='applied')throw fault('invalid_loading_asset',[resource.id]);
if([...runtime.ledger.operations.values()].some(candidate=>candidate.intent.operationType==='document.change'&&candidate.intent.sequence<=record.intent.sequence&&referencedResources(candidate.intent.changes,registry).has(resource.id)))throw fault('invalid_loading_asset',[resource.id]);
if(![...runtime.ledger.operations.values()].some(candidate=>candidate.intent.operationType==='document.change'&&candidate.intent.sequence>record.intent.sequence&&referencedResources(candidate.intent.changes,registry).has(resource.id)))throw fault('invalid_loading_asset',[resource.id]);
}
for(const request of runtime.ledger.requests.values())if(request.kind==='asset.upload'){
const intent=request.operationIds.length===1?runtime.ledger.operations.get(request.operationIds[0])?.intent:undefined,resource=intent?.operationType==='asset.upload'?runtime.resources.get(intent.resourceId):undefined;
if(!resource?.digest||request.protocolVersion!==2||request.canvasIds.length||Object.keys(request.expectedVersions).length)throw fault('invalid_loading_request');
let body:unknown;try{body=JSON.parse(request.body);}catch{throw fault('invalid_loading_request');}
if(!equal(body,{protocol_version:2,request_id:request.id,resource_id:resource.id,operation_id:resource.operationId,sha256:resource.digest,byte_length:resource.byteLength,name:resource.name,mime_type:resource.mimeType}))throw fault('invalid_loading_request',[request.id]);
}
}
const ownership=new WeakMap<Registry,WeakMap<Document,Map<Id,readonly Id[]>>>();
export function validateLoadingAssetUse(intent:OperationIntent,document:Document,runtime:RuntimeState,registry:Registry,seen:Set<Id>):void{
if(runtime.loading?.version!==6)return;
for(const id of referencedResources(intent.changes,registry)){
const proof=runtime.resources.get(id)?.loadingAdmission;if(!proof||seen.has(id))continue;seen.add(id);
let documents=ownership.get(registry);if(!documents){documents=new WeakMap();ownership.set(registry,documents);}let index=documents.get(document);
if(!index){const owners=new Map<Id,Set<Id>>();for(const element of document.elements.values())for(const ref of referencedResources([{kind:'element',id:element.id,before:null,after:element}],registry)){const scopes=owners.get(ref)??new Set<Id>();scopes.add(element.ownerCanvasId);owners.set(ref,scopes);}index=new Map([...owners].map(([id,scopes])=>[id,[...scopes].sort()]));documents.set(document,index);}
if(!equal(index.get(id)??[],[...proof.canvasIds].sort()))throw fault('invalid_loading_asset',[id]);
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ function index(document:Document){let value=relations.get(document);if(!value){c
/** The caller captures this proof with the same document that becomes source bytes. */
export function loadingImportAdmission(snapshot:EngineSnapshot,target:Id,selection?:readonly Target[]):LoadingImportAdmission|undefined {
const state=snapshot.runtime.loading;if(!state)return;
if(state.version!==5||!snapshot.runtime.loadingRead)throw fault('model_loading');
if((state.version!==5&&state.version!==6)||!snapshot.runtime.loadingRead)throw fault('model_loading');
const source=snapshot.runtime.loadingRead,document=snapshot.document,required=new Set<Id>([target]);
const requireScope=(id:Id)=>{required.add(id);if(!ownerReady(state,source,document,id))throw fault('loading_scope_incomplete',[id]);};requireScope(target);
if(selection){
@@ -32,7 +32,7 @@ export function loadingImportAdmission(snapshot:EngineSnapshot,target:Id,selecti
return freeze({version:1 as const,sourceToken:state.sourceToken,operationSequence,requiredCanvasIds:[...required].sort()});
}
export function validateLoadingImportAdmissions(runtime:EngineSnapshot['runtime']):void {
const state=runtime.loading;if(!state||state.version!==5)return;
const state=runtime.loading;if(!state||(state.version!==5&&state.version!==6))return;
let maxSequence=0;for(const record of runtime.ledger.operations.values())maxSequence=Math.max(maxSequence,record.intent.sequence);
for(const job of runtime.imports?.values()??[]){validateImportJob(job);if(job.projectId!==runtime.acknowledged.projectId)throw fault('invalid_loading_import',[job.id]);const proof=job.source?.loadingAdmission;if(!proof||proof.version!==1||proof.sourceToken!==state.sourceToken||proof.operationSequence>maxSequence||!proof.requiredCanvasIds.includes(job.source!.targetCanvasId)||proof.requiredCanvasIds.some(id=>!structuralRequestScope(runtime,runtime.loadingRead!,id)))throw fault('invalid_loading_import',[job.id]);}
}
@@ -40,7 +40,7 @@ export function validateLoadingImportAdmissions(runtime:EngineSnapshot['runtime'
/** Revalidate the frozen copy topology before planning or resuming its source.
* Later source chunks may add unrelated owners, so compare only the closed cut. */
export function validateLoadingCopySource(snapshot:EngineSnapshot,job:ImportJob,document:Document):void {
const state=snapshot.runtime.loading,proof=job.source?.loadingAdmission;if(!state||state.version!==5||!proof)return;
const state=snapshot.runtime.loading,proof=job.source?.loadingAdmission;if(!state||(state.version!==5&&state.version!==6)||!proof)return;
const source=snapshot.runtime.loadingRead!;let expected=source.document;
for(const record of [...snapshot.runtime.ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence))if(record.intent.sequence<=proof.operationSequence)expected=applyChanges(expected,record.intent.changes,false);
const selection=job.preparation?.selection??job.source?.plan?.selection;if(!selection)throw fault('invalid_loading_import',[job.id]);
+18 -13
View File
@@ -10,28 +10,33 @@ const counts=new WeakMap<Document,ReadonlyMap<Id,number>>();
function membership(document:Document){let result=counts.get(document);if(!result){const next=new Map<Id,number>();for(const element of document.elements.values())next.set(element.ownerCanvasId,(next.get(element.ownerCanvasId)??0)+1);result=next;counts.set(document,result);}return result;}
export function sourceClosed(state:LoadingState,source:Capture){return source.document.elements.size===state.totalElements&&source.document.canvases.size===state.declaredCanvasIds.length;}
export function ownerReady(state:LoadingState,source:Capture,document:Document,id:Id){return document.canvases.has(id)&&(!source.versions.has(id)||state.version!==1&&(membership(source.document).get(id)??0)===state.canvasElementCounts[id]);}
function born(state:LoadingState,id:Id){return (state.version===4||state.version===5)&&!Object.hasOwn(state.elementOwners,id);}
function safeData(data:ElementRecord['data'],registry:Registry,component:Id,document:Document,source?:Document,prior?:ElementRecord['data']){
function born(state:LoadingState,id:Id){return (state.version===4||state.version===5||state.version===6)&&!Object.hasOwn(state.elementOwners,id);}
function safeData(data:ElementRecord['data'],registry:Registry,component:Id,document:Document,source?:Document,prior?:ElementRecord['data'],assets?:ReadonlyMap<Id,string>){
const definition=registry.components.get(component);if(!definition?.copy)return false;
for(const key of ['ai','taskId','runId'])if(data[key]!==undefined&&data[key]!==null&&!equal(data[key],prior?.[key]))return false;
for(const key of new Set(['src','assetId',...(definition.resourceFields??[])]))if(data[key]!==undefined&&data[key]!==null&&data[key]!==''){
if((key==='src'&&[...(assets?.values()??[])].some(url=>url===data[key]))||(key!=='src'&&typeof data[key]==='string'&&assets?.has(data[key] as string)))continue;
if(![...document.elements.values(),...(source?.elements.values()??[])].some(element=>registry.resolve(element)==='known'&&equal(element.data[key],data[key])&&(key==='src'||key==='assetId'||registry.components.get(element.componentId)?.resourceFields?.includes(key))))return false;
}
return true;
}
function references(document:Document,registry:Registry){for(const element of document.elements.values())if(registry.resolve(element)==='known'){const schema=registry.components.get(element.componentId)?.copy;for(const reference of schema?.references??[])for(const id of copyReferenceIds(element.data,reference))if(!(reference.target==='element'?document.elements:document.canvases).has(id))throw fault('loading_reference_incomplete',[element.id,id]);}}
export function structuralCommand(command:Command,document:Document,registry:Registry,state:LoadingState,source:Capture):Availability|undefined {
function assetPatch(patch:ElementRecord['data'],registry:Registry,component:Id){
const definition=registry.components.get(component),fields=['src','assetId',...(definition?.resourceFields??[])],keys=Object.keys(patch);
return keys.some(key=>fields.includes(key))&&keys.every(key=>fields.includes(key)||!['presentation','ports','ai','taskId','runId'].includes(key)&&!definition?.copy?.references.some(ref=>ref.path[0]===key||ref.path[0]==='*'));
}
export function structuralCommand(command:Command,document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap<Id,string>):Availability|undefined {
if(state.version<4)return;
const deny:Availability={enabled:false,code:'model_loading',targets:[]},yes:Availability={enabled:true};
if(command.type==='element.create'){const element=command.element;return born(state,element.id)&&!source.document.tombstones.has(element.id)&&ownerReady(state,source,document,element.ownerCanvasId)&&registry.resolve(element)==='known'&&safeData(element.data,registry,element.componentId,document,source.document)?yes:deny;}
if(command.type==='element.create'){const element=command.element;return born(state,element.id)&&!source.document.tombstones.has(element.id)&&ownerReady(state,source,document,element.ownerCanvasId)&&registry.resolve(element)==='known'&&safeData(element.data,registry,element.componentId,document,source.document,undefined,assets)?yes:deny;}
if(command.type==='canvas.create'){const canvas=command.canvas,parent=canvas.parent;return !source.versions.has(canvas.id)&&!!parent&&born(state,parent.entryElementId)&&ownerReady(state,source,document,parent.canvasId)?yes:deny;}
if(command.type==='canvas.reparent')return (!source.versions.has(command.canvasId)||sourceClosed(state,source))&&!!command.parent&&ownerReady(state,source,document,command.parent.canvasId)?yes:deny;
if(command.type==='element.remove'||command.type==='element.move'){const element=document.elements.get(command.target.id);return element&&(born(state,element.id)||sourceClosed(state,source))&&ownerReady(state,source,document,element.ownerCanvasId)&&(command.type!=='element.move'||ownerReady(state,source,document,command.canvasId))?yes:deny;}
if(command.type==='connection.endpoints'){const element=document.elements.get(command.target.id);return element&&ownerReady(state,source,document,element.ownerCanvasId)?yes:deny;}
if(command.type==='element.data'&&(born(state,command.target.id)||sourceClosed(state,source))){const element=document.elements.get(command.target.id);return element&&registry.resolve(element)==='known'&&safeData({...element.data,...command.patch},registry,element.componentId,document,source.document,element.data)?yes:deny;}
if(command.type==='element.data'&&(born(state,command.target.id)||sourceClosed(state,source)||state.version===6&&assetPatch(command.patch,registry,document.elements.get(command.target.id)?.componentId??'')&&ownerReady(state,source,document,document.elements.get(command.target.id)?.ownerCanvasId??''))){const element=document.elements.get(command.target.id);return element&&registry.resolve(element)==='known'&&safeData({...element.data,...command.patch},registry,element.componentId,document,source.document,element.data,assets)?yes:deny;}
}
/** Actual changes, not a command name, establish the mutation's required closure. */
export function assertStructuralChanges(changes:readonly Change[],document:Document,registry:Registry,state:LoadingState,source:Capture):Document {
export function assertStructuralChanges(changes:readonly Change[],document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap<Id,string>):Document {
if(state.version<4)throw fault('model_loading');
let cursor=document;const closed=sourceClosed(state,source);
for(const change of changes){
@@ -42,7 +47,7 @@ export function assertStructuralChanges(changes:readonly Change[],document:Docum
}else if(change.kind==='element'){
if(!born(state,change.id)&&!closed||source.document.tombstones.has(change.id)&&!closed)throw fault('model_loading',[change.id]);
for(const element of [change.before,change.after])if(element&&!ownerReady(state,source,cursor,element.ownerCanvasId))throw fault('model_loading',[element.ownerCanvasId]);
if(change.after&&(registry.resolve(change.after)!=='known'||!safeData(change.after.data,registry,change.after.componentId,cursor,source.document)))throw fault('model_loading',[change.id]);
if(change.after&&(registry.resolve(change.after)!=='known'||!safeData(change.after.data,registry,change.after.componentId,cursor,source.document,undefined,assets)))throw fault('model_loading',[change.id]);
}else{
const element=cursor.elements.get(change.target.id);if(!element||element.incarnation!==change.target.incarnation||registry.resolve(element)!=='known')throw fault('model_loading',[change.target.id]);
if(change.field==='geometry'){if(element.structure!=='node')throw fault('model_loading',[element.id]);}
@@ -51,8 +56,8 @@ export function assertStructuralChanges(changes:readonly Change[],document:Docum
else if(change.field==='ownerCanvasId'){if(!born(state,element.id)&&!closed||!ownerReady(state,source,cursor,element.ownerCanvasId)||!ownerReady(state,source,cursor,change.after as Id))throw fault('model_loading',[element.id]);}
else if(change.field.startsWith('data.')){
const key=change.field.slice(5),definition=registry.components.get(element.componentId),protectedField=['presentation','ports','src','assetId','ai','taskId','runId'].includes(key)||definition?.resourceFields?.includes(key)||definition?.copy?.references.some(ref=>ref.path[0]===key||ref.path[0]==='*');
if(protectedField&&!born(state,element.id)&&!closed)throw fault('model_loading',[element.id]);
const data={...element.data};if(change.afterPresent===false)delete data[key];else data[key]=change.after;if(!safeData(data,registry,element.componentId,cursor,source.document,element.data))throw fault('model_loading',[element.id]);
if(protectedField&&!born(state,element.id)&&!closed&&!(state.version===6&&['src','assetId',...(definition?.resourceFields??[])].includes(key)&&ownerReady(state,source,cursor,element.ownerCanvasId)))throw fault('model_loading',[element.id]);
const data={...element.data};if(change.afterPresent===false)delete data[key];else data[key]=change.after;if(!safeData(data,registry,element.componentId,cursor,source.document,element.data,assets))throw fault('model_loading',[element.id]);
}else throw fault('model_loading',[element.id]);
}
cursor=applyChanges(cursor,[change]);
@@ -71,8 +76,8 @@ export function appliedSource(runtime:RuntimeState,source:Capture):Capture {
return{document,versions};
}
const intentProofs=new WeakMap<Capture,WeakMap<Registry,WeakMap<OperationIntent,{before:Document;after:Document}>>>();
export function validateStructuralIntent(intent:OperationIntent,document:Document,registry:Registry,state:LoadingState,source:Capture):Document {
let registries=intentProofs.get(source);if(!registries){registries=new WeakMap();intentProofs.set(source,registries);}let intents=registries.get(registry);if(!intents){intents=new WeakMap();registries.set(registry,intents);}const previous=intents.get(intent);if(previous?.before===document)return previous.after;
const after=assertStructuralChanges(intent.changes,document,registry,state,source);intents.set(intent,{before:document,after});return after;
const intentProofs=new WeakMap<Capture,WeakMap<Registry,WeakMap<OperationIntent,{before:Document;after:Document;assets?:ReadonlyMap<Id,string>}>>>();
export function validateStructuralIntent(intent:OperationIntent,document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap<Id,string>):Document {
let registries=intentProofs.get(source);if(!registries){registries=new WeakMap();intentProofs.set(source,registries);}let intents=registries.get(registry);if(!intents){intents=new WeakMap();registries.set(registry,intents);}const previous=intents.get(intent);if(previous?.before===document&&previous.assets===assets)return previous.after;
const after=assertStructuralChanges(intent.changes,document,registry,state,source,assets);intents.set(intent,{before:document,after,assets});return after;
}
+18 -14
View File
@@ -1,3 +1,4 @@
import {loadingAssetURLs,validateLoadingAssetAdmissions,validateLoadingAssetUse} from './loading-assets.ts';
import {validateLoadingImportAdmissions} from './loading-import.ts';
import {validateStructuralIntent,structuralCommand,assertStructuralChanges,appliedSource,ownerReady} from './loading-structure.ts';
import {validateCanvasTopology,validateElementOwners,type ElementOwners,validateCanvasElementCounts,type CanvasElementCounts} from './scope-counts.ts';
@@ -9,16 +10,16 @@ import type {CanonicalProposal,RuntimeState} from './contracts.ts';
/** Durable marker for a verified read prefix. Versions 1/2 hold all writes;
* version 3 admits certified scopes with a retained read baseline. No HTTP cursor. */
type LoadingBase={readonly sourceToken:string;readonly declaredCanvasIds:readonly Id[];readonly receivedElements:number;readonly totalElements:number};
export type LoadingState=LoadingBase&({readonly version:1;readonly canvasElementCounts?:never;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:2|3;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:4;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases?:never}|{readonly version:5;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases:readonly CanvasRecord[]});
export type LoadingState=LoadingBase&({readonly version:1;readonly canvasElementCounts?:never;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:2|3;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:4;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases?:never}|{readonly version:5|6;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases:readonly CanvasRecord[]});
export function validateLoadingState(value:LoadingState,document:Document,versions:ReadonlyMap<Id,number>):void{
if(!value||![1,2,3,4,5].includes(value.version))throw fault('loading_interpreter_unavailable');
if(!value||![1,2,3,4,5,6].includes(value.version))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');
if(value.version===1&&value.canvasElementCounts!==undefined)throw fault('invalid_loading_state');
if(value.version<4&&value.elementOwners!==undefined)throw fault('invalid_loading_state');
if(value.version===4||value.version===5){validateElementOwners(value.elementOwners,value.canvasElementCounts,value.totalElements);for(const element of document.elements.values())if(!Object.hasOwn(value.elementOwners,element.id)||value.elementOwners[element.id]!==element.ownerCanvasId)throw fault('invalid_model_identity');}
if(value.version!==5&&value.sourceCanvases!==undefined)throw fault('invalid_loading_state');
if(value.version===5){validateCanvasTopology(value.sourceCanvases,value.elementOwners,document.projectId);if(!equal(value.sourceCanvases.map(canvas=>canvas.id).sort(),[...value.declaredCanvasIds].sort())||[...document.canvases].some(([id,record])=>!equal(record,value.sourceCanvases.find(canvas=>canvas.id===id))))throw fault('invalid_model_topology');}
if(value.version===4||value.version===5||value.version===6){validateElementOwners(value.elementOwners,value.canvasElementCounts,value.totalElements);for(const element of document.elements.values())if(!Object.hasOwn(value.elementOwners,element.id)||value.elementOwners[element.id]!==element.ownerCanvasId)throw fault('invalid_model_identity');}
if(value.version<5&&value.sourceCanvases!==undefined)throw fault('invalid_loading_state');
if(value.version===5||value.version===6){validateCanvasTopology(value.sourceCanvases,value.elementOwners,document.projectId);if(!equal(value.sourceCanvases.map(canvas=>canvas.id).sort(),[...value.declaredCanvasIds].sort())||[...document.canvases].some(([id,record])=>!equal(record,value.sourceCanvases.find(canvas=>canvas.id===id))))throw fault('invalid_model_topology');}
if(value.version!==1){validateCanvasElementCounts(value.canvasElementCounts,value.declaredCanvasIds,value.totalElements);const counts=new Map<Id,number>();for(const element of document.elements.values()){const count=(counts.get(element.ownerCanvasId)??0)+1;if(count>value.canvasElementCounts[element.ownerCanvasId])throw fault('invalid_model_coverage');counts.set(element.ownerCanvasId,count);}}
}
const denied:Availability=Object.freeze({enabled:false,code:'model_loading',targets:[]});
@@ -30,8 +31,8 @@ function pathAllowed(id:Id,document:Document,registry:Registry,loading?:LoadingS
const element=document.elements.get(id);if((!loading||loading.version===1)||element?.structure!=='connection'||registry.resolve(element)!=='known')return false;
return (directCounts(base).get(element.ownerCanvasId)??0)===loading.canvasElementCounts[element.ownerCanvasId];
}
export function loadingCommand(command:Command,document:Document,registry:Registry,loading?:LoadingState,base=document,source?:CanonicalProposal):Availability{
if(loading&&(loading.version===4||loading.version===5)&&source){const scoped=structuralCommand(command,document,registry,loading,source);if(scoped)return scoped;}
export function loadingCommand(command:Command,document:Document,registry:Registry,loading?:LoadingState,base=document,source?:CanonicalProposal,assets?:ReadonlyMap<Id,string>):Availability{
if(loading&&(loading.version===4||loading.version===5||loading.version===6)&&source){const scoped=structuralCommand(command,document,registry,loading,source,assets);if(scoped)return scoped;}
if(command.type==='connection.path')return pathAllowed(command.target.id,document,registry,loading,base)?{enabled:true}:denied;
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;
@@ -40,8 +41,8 @@ export function loadingCommand(command:Command,document:Document,registry:Regist
}
/** 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,loading?:LoadingState,source?:CanonicalProposal):void{
if(loading&&(loading.version===4||loading.version===5)&&source){assertStructuralChanges(changes,document,registry,loading,source);return;}
export function assertLoadingChanges(changes:readonly Change[],document:Document,registry:Registry,loading?:LoadingState,source?:CanonicalProposal,assets?:ReadonlyMap<Id,string>):void{
if(loading&&(loading.version===4||loading.version===5||loading.version===6)&&source){assertStructuralChanges(changes,document,registry,loading,source,assets);return;}
for(const change of changes){
if(change.kind!=='field')throw fault('model_loading');
const element=document.elements.get(change.target.id);
@@ -54,12 +55,15 @@ export function validateLoadingRuntime(runtime:RuntimeState,registry:Registry):v
if(runtime.loading.version>=3&&!source||runtime.loading.version<3&&runtime.loadingRead)throw fault('invalid_loading_state');
validateLoadingState(runtime.loading,source?.document??runtime.acknowledged,source?.versions??runtime.versions);
if(source)validateLoadingRead(runtime,registry);
if(runtime.loading.version<3&&runtime.ledger.requests.size||runtime.resources.size||runtime.runs.size||runtime.loading.version<5&&runtime.imports?.size||runtime.orphanFiles?.size||runtime.recoveries.size)throw fault('invalid_loading_state');
validateLoadingImportAdmissions(runtime);
if(runtime.loading.version<3&&runtime.ledger.requests.size||runtime.loading.version<6&&runtime.resources.size||runtime.runs.size||runtime.loading.version<5&&runtime.imports?.size||runtime.orphanFiles?.size||runtime.recoveries.size)throw fault('invalid_loading_state');
validateLoadingImportAdmissions(runtime);validateLoadingAssetAdmissions(runtime,registry);
const assets=loadingAssetURLs(runtime),assetUses=new Set<Id>();
let logical=source?.document??runtime.acknowledged;
for(const record of [...runtime.ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){
if(record.intent.operationType==='asset.upload'&&runtime.loading.version===6){if(!assets.has(record.intent.resourceId))throw fault('invalid_loading_asset');continue;}
if(record.intent.operationType!=='document.change'||runtime.loading.version<3&&(record.requestId||record.resolution||!['pending','blocked'].includes(record.phase)))throw fault('invalid_loading_state');
if(runtime.loading.version>=4){logical=validateStructuralIntent(record.intent,logical,registry,runtime.loading,source!);}else assertLoadingChanges(record.intent.changes,source?.document??runtime.acknowledged,registry,runtime.loading);
if(runtime.loading.version>=4){logical=validateStructuralIntent(record.intent,logical,registry,runtime.loading,source!,runtime.loading.version===6?assets:undefined);}else assertLoadingChanges(record.intent.changes,source?.document??runtime.acknowledged,registry,runtime.loading);
validateLoadingAssetUse(record.intent,logical,runtime,registry,assetUses);
}
}
export function validatePrefixExtension(previous:Document,previousVersions:ReadonlyMap<Id,number>,state:LoadingState,next:Document,versions:ReadonlyMap<Id,number>,nextState:LoadingState,registry:Registry):void{
@@ -92,7 +96,7 @@ export function loadingQueryCoverage(snapshot:import('./contracts.ts').EngineSna
const loading=snapshot.runtime.loading,base=snapshot.runtime.loadingRead?.document??snapshot.runtime.acknowledged;
if(!loading)return{projectComplete:true,topologyComplete:true,completeCanvasIds:[...snapshot.document.canvases.keys()]};
const counts=directCounts(base);
return{...((loading.version===4||loading.version===5)?{elementIds:namespace(loading.elementOwners),loadedElementIds:loadedNamespace(base)}:{}),projectComplete:false,topologyComplete:base.canvases.size===loading.declaredCanvasIds.length,completeCanvasIds:(loading.version===4||loading.version===5)?[...snapshot.document.canvases.keys()].filter(id=>ownerReady(loading,snapshot.runtime.loadingRead!,snapshot.document,id)):loading.version!==1?[...base.canvases.keys()].filter(id=>snapshot.document.canvases.has(id)&&(counts.get(id)??0)===loading.canvasElementCounts[id]):[]};
return{...((loading.version===4||loading.version===5||loading.version===6)?{elementIds:namespace(loading.elementOwners),loadedElementIds:loadedNamespace(base)}:{}),projectComplete:false,topologyComplete:base.canvases.size===loading.declaredCanvasIds.length,completeCanvasIds:(loading.version===4||loading.version===5||loading.version===6)?[...snapshot.document.canvases.keys()].filter(id=>ownerReady(loading,snapshot.runtime.loadingRead!,snapshot.document,id)):loading.version!==1?[...base.canvases.keys()].filter(id=>snapshot.document.canvases.has(id)&&(counts.get(id)??0)===loading.canvasElementCounts[id]):[]};
}
/** Validate the retained source separately from the moving ACK baseline. Only
@@ -133,7 +137,7 @@ function validateStructuralRead(runtime:RuntimeState,registry:Registry){
const source=runtime.loadingRead!,expected=appliedSource(runtime,source),ack=runtime.acknowledged;
if(expected.document.projectId!==ack.projectId||!equal(Object.fromEntries(expected.document.canvases),Object.fromEntries(ack.canvases))||!equal(Object.fromEntries(expected.document.elements),Object.fromEntries(ack.elements))||!equal(Object.fromEntries(expected.document.tombstones),Object.fromEntries(ack.tombstones))||!equal(Object.fromEntries(expected.versions),Object.fromEntries(runtime.versions)))throw fault('invalid_loading_read');
validateDocument(ack,registry);
for(const request of runtime.ledger.requests.values())if(request.protocolVersion!==2||!request.canvasIds.length||Object.keys(request.expectedVersions).length!==request.canvasIds.length||request.canvasIds.some(id=>!structuralRequestScope(runtime,source,id)||!Number.isSafeInteger(request.expectedVersions[id])||request.expectedVersions[id]<(source.versions.get(id)??0)||request.expectedVersions[id]>(runtime.versions.get(id)??0)))throw fault('invalid_loading_request');
for(const request of runtime.ledger.requests.values())if(!(runtime.loading?.version===6&&request.kind==='asset.upload')&&(request.protocolVersion!==2||!request.canvasIds.length||Object.keys(request.expectedVersions).length!==request.canvasIds.length||request.canvasIds.some(id=>!structuralRequestScope(runtime,source,id)||!Number.isSafeInteger(request.expectedVersions[id])||request.expectedVersions[id]<(source.versions.get(id)??0)||request.expectedVersions[id]>(runtime.versions.get(id)??0))))throw fault('invalid_loading_request');
}
export function structuralRequestScope(runtime:RuntimeState,source:CanonicalProposal,id:Id):boolean {
+2 -2
View File
@@ -52,8 +52,8 @@ export function encodeRecoveryValue(input:unknown):Json {
}
/** Only codec-owned, validated immutable images qualify for the storage copy fast path. */
export function sealRecoveryImage(formatVersion:1|2|3|4|5|6|7|8|9|10|11,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
if(![1,2,3,4,5,6,7,8,9,10,11].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|8|9|10|11|12,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
if(![1,2,3,4,5,6,7,8,9,10,11,12].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);}
+4 -3
View File
@@ -28,7 +28,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage {
operations: [...snapshot.operations],
runtime: { ...(snapshot.runtime.loadingRead?{loadingRead:{document:documentValue(snapshot.runtime.loadingRead.document),versions:[...snapshot.runtime.loadingRead.versions]}}:{}), ...(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.runtime.loading?(snapshot.runtime.loading.version===5?11:snapshot.runtime.loading.version===4?10:snapshot.runtime.loading.version===3?9:snapshot.runtime.loading.version===2?8: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);
return sealRecoveryImage(snapshot.runtime.loading?(snapshot.runtime.loading.version===6?12:snapshot.runtime.loading.version===5?11:snapshot.runtime.loading.version===4?10:snapshot.runtime.loading.version===3?9:snapshot.runtime.loading.version===2?8: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<string, unknown>;
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
@@ -92,12 +92,12 @@ 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,6,7,8,9,10,11].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
if (![1,2,3,4,5,6,7,8,9,10,11,12].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);
if(runtime.loading!==undefined&&image.formatVersion!==(object(runtime.loading).version===5?11:object(runtime.loading).version===4?10:object(runtime.loading).version===3?9:object(runtime.loading).version===2?8:7))throw fault('unsupported_recovery_version');
if(runtime.loading!==undefined&&image.formatVersion!==(object(runtime.loading).version===6?12:object(runtime.loading).version===5?11:object(runtime.loading).version===4?10:object(runtime.loading).version===3?9:object(runtime.loading).version===2?8: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');
@@ -173,6 +173,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.loadingAdmission!==undefined){const proof=object(raw.loadingAdmission);if(proof.version!==1)throw fault('loading_asset_interpreter_unavailable');string(proof.sourceToken);string(proof.sourceURL);const canvases=strings(proof.canvasIds);if((proof.sourceToken as string).length>256||(proof.sourceURL as string).length>8192||!canvases.length||new Set(canvases).size!==canvases.length)throw fault('corrupt_recovery_image');}
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);}}
+2 -1
View File
@@ -8,7 +8,8 @@ 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 sizing?: DefaultImageSizing; readonly image?:ImageDecodeState; readonly digest?: string; readonly retirement?: {readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number}; readonly stored?: StoredAsset };
export type LoadingAssetAdmission = {readonly version:1;readonly sourceToken:string;readonly sourceURL:string;readonly canvasIds:readonly Id[]};
export type ResourceRecord = { readonly loadingAdmission?:LoadingAssetAdmission; 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;
+1 -1
View File
@@ -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 | 7 | 8 | 9 | 10 | 11; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; 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};
+1 -1
View File
@@ -30,7 +30,7 @@ function purgedFileIds(image:RecoveryImage):Set<string>{
return result;
}
function validateImage(image: RecoveryImage) {
if (![1,2,3,4,5,6,7,8,9,10,11].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
if (![1,2,3,4,5,6,7,8,9,10,11,12].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); });
+12 -1
View File
@@ -687,6 +687,15 @@ async function loadingImportRecoveryCase(name,assert){
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
async function loadingAssetRecoveryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:root},registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}}),frame={...node('F'),componentId:'frame'},canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],full=createDocument(root,canvases,[frame,node('A'),node('C',child)]),prefix=createDocument(root,canvases,[frame,node('C',child)]),loading={version:6,sourceToken:'browser-import-cut',declaredCanvasIds:[root,child],sourceCanvases:canvases,receivedElements:2,totalElements:3,canvasElementCounts:{[root]:2,[child]:1},elementOwners:{F:root,A:root,C:child}},database=name+'-loading-asset',storage=createBrowserStorage({name:database}),versions=new Map([[root,1],[child,1]]);let server=full,engine;const calls=[];
const persistence=createFoundryPersistence({registry,workspaceId:scope.workspaceId,rootId:root,transport:async input=>{assert(input.method==='POST','unexpected import read');calls.push(input);const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert(body.expected_versions[id]===versions.get(id),'stale partial import version');server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));for(const id of participants)versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,canvases:[...server.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...server.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)])),removedCanvasIds:[]}}};}});
let time=0;const receipts=new Map(),uploads=[],assets={sourceURL:id=>`https://assets.example/${id}`,displayURL:id=>`/assets/${id}`,async upload(request,bytes){uploads.push(request);const body=JSON.parse(request.body),stored={id:body.resource_id,url:assets.sourceURL(body.resource_id),bytes:bytes.size,width:20,height:10,mime_type:bytes.type};receipts.set(request.id,stored);throw Error('lost image ACK');},async query(request){return{outcome:'applied',canonical:receipts.get(request.id)};},accept:(_request,status)=>status.canonical},clock={now:()=>time,setTimer:()=>0,clearTimer(){}};
const options={scope,registry,storage,persistence,assets,clock,document:prefix,versions:new Map(versions),loading};
try{engine=await openProjectEngine(options);assert(!engine.canStageFiles(root).enabled&&engine.canStageFiles(child).enabled,'image admission ignored coverage');let resource;const result=engine.stageFiles([{name:'picture.png',bytes:new Blob(['durable image bytes'],{type:'image/png'})}],([ref],tx)=>{resource=ref;tx.dispatch({type:'element.create',element:{...node('picture',child),componentId:'image',data:{title:'Picture',text:'',assetId:ref.id,src:ref.src}}});});assert(result.status==='applied'&&engine.getSnapshot().document.elements.has('picture'),'image did not display locally');await engine.settle();assert(uploads.length===1&&calls.length===0,'image write bypassed upload dependency');const request=uploads[0],display=await engine.displayResource(resource.id);assert((await(await fetch(display.url)).blob()).size>0,'saved Blob could not display');display.release();await engine.close();time=10_000;engine=await openProjectEngine(options);await engine.settle();assert(uploads.length===1&&engine.getSnapshot().runtime.ledger.requests.get(request.id).body===request.body,'lost ACK changed the upload identity');assert(server.elements.get('picture').data.assetId===resource.id,'reopened upload did not release the image document');assert(engine.editor.history.undo().status==='applied','image undo failed');await engine.settle();assert(!server.elements.has('picture'),'image undo left a server element');assert(engine.editor.history.redo().status==='applied','image redo failed');await engine.settle();assert(server.elements.get('picture').incarnation===2&&uploads.length===1,'image redo uploaded again or reused the deleted incarnation');assert((await engine.loading.complete(full,new Map([[root,1],[child,1]]),{sourceToken:loading.sourceToken})).status==='applied','old tail could not complete with stored image');await engine.settle();assert(engine.getSnapshot().document.elements.get('picture').incarnation===2&&engine.getSnapshot().runtime.resources.get(resource.id).stored,'old tail lost resource/history');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
async function loadingRecoveryCase(name,assert,coverage=false,scoped=false,structural=false){
const format=structural?10:scoped?9:coverage?8:7;name+=structural?"-structural":scoped?"-scoped":coverage?"-coverage":"-legacy";
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={...(coverage?{version:structural?4:scoped?3:2,canvasElementCounts:{[root]:2},...(structural?{elementOwners:{A:root,B:root}}:{})}:{version:1}),sourceToken:'browser-captured-read',declaredCanvasIds:[root],receivedElements:1,totalElements:2};
@@ -740,7 +749,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: 12 } }), 'unsupported_recovery_version');
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 13 } }), '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.
@@ -801,6 +810,8 @@ export async function run() {
results.push('recovery v10 preserves the source identity manifest and atomically retires its checkpoint/journal dependencies before releasing a completed model');
await scopedAckRecoveryCase(name,assert,true);
results.push('v10 creates a new element before the unrelated tail, recovers its lost ACK by the original key through IndexedDB and retains the birth across old-source completion');
await loadingAssetRecoveryCase(name,assert);
results.push('v12 scoped images save Blob/history/upload together, recover a lost ACK through IndexedDB without reupload, preserve undo/redo and survive old-source completion');
await loadingImportRecoveryCase(name,assert);
results.push('v11 partial import persists its source Blob through IndexedDB pause/reopen, preserves mapped IDs and undo while unrelated model records remain unloaded');
await scopedNetworkLoaderCase(assert);
+15 -7
View File
@@ -7,13 +7,14 @@ import {createDocument,createRegistry,builtinComponents} from '../../src/lib/can
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {createModelPreview} from '../../src/lib/canvas-runtime/model-preview.ts';
import {createFoundryTransport,createFoundryPersistence,loadFoundryModel} from '../../src/lib/canvas-service/foundry.ts';
import {createFoundryAssets} from '../../src/lib/canvas-service/assets.ts';
import {decodeModelStream} from '../../src/lib/canvas-service/model-stream.ts';
const {FOUNDRY_HTTP_URL:url,FOUNDRY_TEST_PROJECT:project,FOUNDRY_TEST_ACCOUNT:accountId,FOUNDRY_TEST_WORKSPACE:workspaceId,FOUNDRY_TEST_ROOT:rootId,FOUNDRY_TEST_TOKEN:token}=process.env;
assert.ok(url&&project&&accountId&&workspaceId&&rootId&&token,'requires isolated Foundry HTTP fixture');
const registry=createRegistry(builtinComponents()),basePath=`${url}/projects/${project}/`,calls=[];
let loseNextAck=false;
let loseNextAck=false,loseNextAssetAck=false;
const rawTransport=createFoundryTransport({basePath,accountId,isCurrentIdentity:()=>true,fetch:async(resource,init)=>{const headers=new Headers(init.headers);headers.set('Authorization',`Bearer ${token}`);return fetch(resource,{...init,headers});}});
const transport=async input=>{calls.push({method:input.method,path:input.path,requestId:input.requestId,body:input.body});const result=await rawTransport(input);if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}return result;};
const transport=async input=>{calls.push({method:input.method,path:input.path,requestId:input.requestId,body:input.body});const result=await rawTransport(input);if(loseNextAssetAck&&input.method==='POST'&&input.path.endsWith('/assets')&&result.status===200){loseNextAssetAck=false;throw Error('injected upload response loss');}if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}return result;};
const path=`workspaces/${workspaceId}/materials/${rootId}/canvas`;
const migration=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(migration.status,'migration-required');
const migrationId=randomUUID(),migrationBody=JSON.stringify({protocol_version:2,request_id:migrationId,expected_versions:Object.fromEntries(migration.sourceVersions)});
@@ -23,8 +24,8 @@ let initial=await loadFoundryModel(transport,workspaceId,rootId,registry);assert
const sourceFrame=[...initial.document.elements.values()].find(element=>element.componentId==='frame'),sourceMember=[...initial.document.elements.values()].find(element=>element.structure==='node'&&element.ownerCanvasId!==rootId);assert.ok(sourceFrame&&sourceMember);
const scope={accountId,workspaceId,projectId:rootId},files=new Map(),errors=[];let image=null,owner,now=0;
const storage={async claim(){assert.equal(owner,undefined);const held={scope,fence:1,active:true,async release(){held.active=false;owner=undefined;}};owner=held;return held;},async recover(){return structuredClone(image);},async commit(held,batch){assert.strictEqual(held,owner);const next=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=next;return{durableRootRevision:next.rootRevision};},async readFile(_held,id){return files.get(id);},close(){}};
const persistence=createFoundryPersistence({registry,workspaceId,rootId,transport}),clock={now:()=>now,setTimer:()=>0,clearTimer(){}};
const open=(extra={})=>openProjectEngine({scope,registry,storage,persistence,document:initial.document,versions:initial.versions,clock,createId:randomUUID,onError:error=>errors.push(error),...extra});
const persistence=createFoundryPersistence({registry,workspaceId,rootId,transport}),assets=createFoundryAssets({workspaceId,project,origin:'https://api.example.com',transport}),clock={now:()=>now,setTimer:()=>0,clearTimer(){}};
const open=(extra={})=>openProjectEngine({scope,registry,storage,persistence,assets,document:initial.document,versions:initial.versions,clock,createId:randomUUID,onError:error=>errors.push(error),...extra});
const node=(id,ownerCanvasId=rootId)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
let engine=await open();
try{
@@ -53,11 +54,18 @@ try{
const value=preview.update(chunk.value);if(chunk.value.status==='receiving'){capture=value;sourceToken=chunk.snapshotToken;sourceVersions=chunk.versions;if(chunk.value.receivedElements>0)break;}
}
assert.ok(capture.sourceCanvases&&capture.elementOwners);assert.ok(capture.document.elements.size<initial.document.elements.size);assert.equal(capture.document.elements.has(tail.at(-1).id),false);
const loading={version:5,sourceToken,sourceCanvases:capture.sourceCanvases,elementOwners:capture.elementOwners,canvasElementCounts:capture.canvasElementCounts,declaredCanvasIds:capture.sourceCanvases.map(canvas=>canvas.id),receivedElements:capture.receivedElements,totalElements:capture.totalElements};
engine=await open({document:capture.document,versions:sourceVersions,loading});assert.equal(engine.editor.getQueries().membersResult(sourceMember.ownerCanvasId).complete,false);assert.equal(engine.imports.canStart(childId).enabled,true);const partialId=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'partial-native',record:node('partial-native','$target')})+'\n']),childId);await engine.settle();assert.equal(engine.imports.getJobs().get(partialId).status,'completed');assert.equal(engine.loading.getState().version,5);
const loading={version:6,sourceToken,sourceCanvases:capture.sourceCanvases,elementOwners:capture.elementOwners,canvasElementCounts:capture.canvasElementCounts,declaredCanvasIds:capture.sourceCanvases.map(canvas=>canvas.id),receivedElements:capture.receivedElements,totalElements:capture.totalElements};
engine=await open({document:capture.document,versions:sourceVersions,loading});assert.equal(engine.editor.getQueries().membersResult(sourceMember.ownerCanvasId).complete,false);assert.equal(engine.imports.canStart(childId).enabled,true);const partialId=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'partial-native',record:node('partial-native','$target')})+'\n']),childId);await engine.settle();assert.equal(engine.imports.getJobs().get(partialId).status,'completed');assert.equal(engine.loading.getState().version,6);
const partialCopyId=await engine.imports.duplicate([{id:frame.id,incarnation:1}],{viewCanvasId:rootId});await engine.settle();const partialCopy=engine.imports.getJobs().get(partialCopyId);assert.equal(partialCopy.status,'completed');assert.equal(engine.getSnapshot().document.elements.has(tail.at(-1).id),false);const partialFrame=partialCopy.stableIds['element:'+frame.id];assert.ok(partialFrame);
// The upload commits on the real server, then its response is deliberately
// lost. Reopening must query that upload before either image document sends.
loseNextAssetAck=true;const imageId=randomUUID();let imageResource;
const imageResult=engine.stageFiles([{name:'live.png',bytes:new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAIAAAASFvFNAAAAEElEQVR4nGP4z8AAQQxwFgBB0gX7h/C5SAAAAABJRU5ErkJggg==','base64')],{type:'image/png'})}],([ref],tx)=>{imageResource=ref;tx.dispatch({type:'element.create',element:{...node(imageId,childId),componentId:'image',data:{title:'Live scoped image',text:'',assetId:ref.id,src:ref.src}}});});assert.equal(imageResult.status,'applied');assert.equal(engine.getSnapshot().document.elements.has(imageId),true);await engine.settle();const upload=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='asset.upload');assert.ok(upload);assert.equal(upload.phase,'waiting');const uploadCount=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length;
const imageCopyId=await engine.imports.duplicate([{id:imageId,incarnation:1}],{viewCanvasId:childId});await engine.settle();const imageCopy=engine.imports.getJobs().get(imageCopyId);assert.equal(imageCopy.status,'completed');const copiedImage=imageCopy.stableIds['element:'+imageId];assert.equal(engine.getSnapshot().document.elements.get(copiedImage).data.assetId,imageResource.id);assert.equal(engine.getSnapshot().document.elements.has(tail.at(-1).id),false);
await engine.close();now+=10_000;engine=await open({document:capture.document,versions:sourceVersions,loading});await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(upload.id).phase,'resolved');assert.equal(engine.getSnapshot().runtime.ledger.requests.get(upload.id).body,upload.body);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/assets/operations/'+upload.id)));
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(imageId).data.assetId,imageResource.id);assert.equal(live.document.elements.get(copiedImage).data.assetId,imageResource.id);
let terminal;for await(const chunk of stream){preview.update(chunk.value);if(chunk.value.status==='ready')terminal=chunk.value;}assert.ok(terminal,'real source never reached terminal and EOF');
assert.equal((await engine.loading.complete(terminal.document,sourceVersions,{sourceToken})).status,'applied');await engine.settle();live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.ok(live.document.elements.has(engine.imports.getJobs().get(partialId).stableIds['element:partial-native']));assert.ok(live.document.elements.has(partialFrame));assert.ok(live.document.elements.has(tail.at(-1).id));assert.ok([...live.document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId===anchor.id&&element.target.elementId===partialFrame));
assert.ok(errors.every(error=>String(error).includes('injected response loss')),'unexpected engine error: '+errors.map(String).join(','));
console.log(JSON.stringify({status:'ok',migrationRetry:true,realHttp:true,lostAckRecovered:true,atomicPartition:true,deepCopyUpperEdge:true,nativePartialReport:true,loadingImportOldCapture:true,unloadedTailDuringCopy:true,requests:calls.length}));
console.log(JSON.stringify({status:'ok',migrationRetry:true,realHttp:true,lostAckRecovered:true,atomicPartition:true,deepCopyUpperEdge:true,nativePartialReport:true,loadingImportOldCapture:true,unloadedTailDuringCopy:true,scopedImageLostAck:true,copiedPendingImageSharesUpload:true,requests:calls.length}));
}finally{await engine.close();}
+18
View File
@@ -288,3 +288,21 @@ test('import availability rejects stale selection, missing target and policy den
const f=fixture();let engine=await f.open({document:full,loading:undefined});try{assert.equal(engine.imports.canStart('missing').code,'canvas_not_loaded');assert.equal(engine.imports.canDuplicate([],{viewCanvasId:root}).code,'empty_copy_selection');assert.equal(engine.imports.canDuplicate([{id:'A',incarnation:2}]).code,'stale_incarnation');}finally{await engine.close();}
engine=await f.open({document:full,loading:undefined,policy:()=>({enabled:false,code:'permission_denied'})});try{assert.equal(engine.imports.canStart(root).code,'permission_denied');assert.equal(engine.imports.canDuplicate([target]).code,'permission_denied');}finally{await engine.close();}
});
const assetLoading={...jobLoading,version:6};
function assetPort(f){const uploads=[],receipts=new Map();let lose=false;const assets={sourceURL:id=>`https://assets.example/${id}`,displayURL:id=>`/assets/${id}`,async upload(request,bytes){const value=JSON.parse(request.body);assert.equal(f.storage.files.get(value.resource_id).size,bytes.size);assert.ok(f.storage.image().payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.phase==='dispatch-marked'));uploads.push(request);const stored={id:value.resource_id,url:assets.sourceURL(value.resource_id),bytes:bytes.size,width:20,height:10,mime_type:bytes.type};receipts.set(request.id,stored);if(lose)throw Error('lost upload ACK');return{outcome:'applied',canonical:stored};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:receipts.get(request.id)}:{outcome:'unresolved'};},accept:(_request,status)=>status.canonical};return{assets,uploads,lose(){lose=true;}};}
function stageLoadingImage(engine,owner=child,id='partial-image'){let ref;const result=engine.stageFiles([{name:'picture.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([resource],tx)=>{ref=resource;tx.dispatch({type:'element.create',element:{...node(id,owner),componentId:'image',data:{title:'Picture',text:'',assetId:ref.id,src:ref.src}}});});return{result,ref};}
test('v6 displays, persists and uploads a scoped image before another owner tail; undo/redo and old completion preserve its resource',async()=>{
const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{assert.equal(engine.canStageFiles(child).enabled,true);assert.equal(engine.canStageFiles(root).enabled,false);const before=engine.getSnapshot();assert.equal(stageLoadingImage(engine,root,'denied-image').result.status,'rejected');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.storage.files.size,0);
const {result,ref}=stageLoadingImage(engine);assert.equal(result.status,'applied');assert.equal(engine.getSnapshot().document.elements.get('partial-image').data.assetId,ref.id);assert.equal(port.uploads.length,0);await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal(f.storage.image().formatVersion,12);decodeRecovery(f.storage.image(),registry);for(const mutate of [image=>image.payload.runtime.resources[0][1].loadingAdmission.sourceToken='other-source',image=>image.payload.runtime.resources[0][1].loadingAdmission.canvasIds=[root],image=>image.payload.runtime.resources[0][1].loadingAdmission.sourceURL='https://wrong.example/',image=>image.payload.runtime.resources[0][1].loadingAdmission.version=2,image=>{const row=image.payload.runtime.ledger.requests.find(([,row])=>row.kind==='asset.upload')[1];const body=JSON.parse(row.body);body.name='changed';row.body=JSON.stringify(body);},image=>image.formatVersion=11]){const image=f.storage.image();mutate(image);assert.throws(()=>decodeRecovery(image,registry));}assert.equal(engine.loading.getState().version,6);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('partial-image'),false);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('partial-image').incarnation,2);assert.equal(port.uploads.length,1);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);assert.equal(engine.getSnapshot().document.elements.get('partial-image').incarnation,2);assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
test('v6 queries a lost upload ACK after reopening and never resubmits bytes or loses the pending image to the source tail',async()=>{
let now=0;const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),clock={now:()=>now,setTimer:()=>0,clearTimer(){}};port.lose();let engine=await f.open({document,loading:assetLoading,assets:port.assets,clock});try{const {ref}=stageLoadingImage(engine);await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.calls.length,0);const request=port.uploads[0];assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');const copyId=await engine.imports.duplicate([{id:'partial-image',incarnation:1}],{viewCanvasId:child});await engine.settle();const copy=engine.imports.getJobs().get(copyId);assert.equal(copy.status,'completed');const copied=copy.stableIds['element:partial-image'];assert.equal(engine.getSnapshot().document.elements.get(copied).data.assetId,ref.id);assert.equal(f.calls.length,0);decodeRecovery(f.storage.image(),registry);await engine.close();now=10_000;engine=await f.open({document,loading:assetLoading,assets:port.assets,clock});await engine.settle();assert.equal(port.uploads.length,1);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,request.body);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(f.server().elements.get(copied).data.assetId,ref.id);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(port.uploads.length,1);}finally{await engine.close();}
});
test('v6 replaces an image in a complete original owner and preserves both resource choices through undo/redo',async()=>{
const image={...c,componentId:'image',data:{title:'Original image',text:'',assetId:'old-asset',src:'https://assets.example/old-asset'}},server=createDocument(root,canvases,[a,b,frame,image,edge]),f=fixture(server),port=assetPort(f),document=createDocument(root,canvases,[a,frame,image]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{const result=engine.stageFiles([{name:'replacement.png',bytes:new Blob(['replacement'],{type:'image/png'})}],([ref],tx)=>tx.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{assetId:ref.id,src:ref.src}}));assert.equal(result.status,'applied');await engine.settle();const replacement=f.server().elements.get('C').data.assetId;assert.notEqual(replacement,'old-asset');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.assetId,'old-asset');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.assetId,replacement);assert.equal(port.uploads.length,1);decodeRecovery(f.storage.image(),registry);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
test('v6 failed Blob/root persistence keeps the local picture visible and sends neither upload nor document until retry',async()=>{
const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{await engine.persist();f.storage.broken=true;const {result,ref}=stageLoadingImage(engine);assert.equal(result.status,'applied');const display=await engine.displayResource(ref.id);assert.equal(await(await fetch(display.url)).text(),'image bytes');display.release();await assert.rejects(engine.persist(),/quota/);await new Promise(resolve=>setTimeout(resolve,10));assert.equal(port.uploads.length,0);assert.equal(f.calls.length,0);assert.equal(engine.getSnapshot().document.elements.get('partial-image').data.assetId,ref.id);f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{f.storage.broken=false;await engine.close();}
});
+2
View File
@@ -51,6 +51,8 @@ try {
export const scoped:LoadingState={...certified,version:3};
export const structural:LoadingState={...scoped,version:4,elementOwners:{node:'root'}};
export const importable:LoadingState={...structural,version:5,sourceCanvases:[{id:'root',parent:null}]};
export const images:LoadingState={...importable,version:6};
export function imageAvailability(engine:Awaited<ReturnType<typeof openProjectEngine>>){return engine.canStageFiles('root');}
export function importAvailability(engine:Awaited<ReturnType<typeof openProjectEngine>>){return [engine.imports.canStart('root'),engine.imports.canDuplicate([{id:'node',incarnation:1}],{viewCanvasId:'root'})];}
export function inspectPartial(engine:Awaited<ReturnType<typeof openProjectEngine>>){
const queries=engine.editor.getQueries(),lookup=queries.lookupElement('node');
+1 -1
View File
@@ -24,5 +24,5 @@ test('cached subtree height cannot bypass the depth bound and omitted object fie
const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n}));
});
test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(7,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(8,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(9,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(10,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(11,1,1,{})),true);assert.throws(()=>sealRecoveryImage(12,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.equal(isSealedRecoveryImage(sealRecoveryImage(7,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(8,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(9,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(10,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(11,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(12,1,1,{})),true);assert.throws(()=>sealRecoveryImage(13,1,1,{}),/corrupt_recovery_image/);
});
+4 -2
View File
@@ -378,10 +378,12 @@ try {
await evaluate(`(()=>{const input=document.querySelector('input[data-canvas-import]'),transfer=new DataTransfer();transfer.items.add(new File([JSON.stringify({kind:'element',key:'partial-import',record:{id:'partial-import',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'Imported before tail',text:'streaming'},geometry:{x:0,y:0,width:100,height:80,rotation:0}}})+'\\n'],'partial.ndjson',{type:'application/x-ndjson'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
for(let i=0;i<200&&![...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail');i++)await pause(20);assert.ok([...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail'),'partial page import did not reach server before tail');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('button[aria-label="深复制选中内容"]').disabled`,'closed source copy stayed disabled during loading');const copyCount=documents.get(scopedRoot).elements.size;await click('深复制选中内容');
for(let i=0;i<200&&documents.get(scopedRoot).elements.size===copyCount;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.size,copyCount+1,'page copy did not publish before tail');await until(`!!document.querySelector('button[aria-label="深复制选中内容"]')`,'copy did not finish');const scopeVersion=versions.get(scopedRoot);
for(let i=0;i<200&&documents.get(scopedRoot).elements.size===copyCount;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.size,copyCount+1,'page copy did not publish before tail');await until(`!!document.querySelector('button[aria-label="深复制选中内容"]')`,'copy did not finish');assert.equal(await evaluate(`document.querySelector('button[aria-label="添加图片"]').disabled`),false);const beforeImage=documents.get(scopedRoot).elements.size,uploadsBefore=uploads.length;
await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),transfer=new DataTransfer();transfer.items.add(new File([bytes],'loading-pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
for(let i=0;i<200&&documents.get(scopedRoot).elements.size===beforeImage;i++)await pause(20);const loadingImage=[...documents.get(scopedRoot).elements.values()].find(element=>element.componentId==='image');assert.ok(loadingImage,'scoped image waited for the unrelated tail');assert.equal(uploads.length,uploadsBefore+1);assert.equal(loadingImage.geometry.height,130,'scoped image lost default sizing');assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);const scopeVersion=versions.get(scopedRoot);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'original text focus did not return after structural history');
await evaluate('window.__scopeTextarea=document.activeElement;true');releaseScopeStream();await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`,'original stream did not complete after a scoped ACK');assert.equal(await evaluate('window.__scopeTextarea.isConnected&&document.activeElement===window.__scopeTextarea'),true);assert.equal(await evaluate('window.__scopeTextarea.value'),scopeText);assert.equal(versions.get(scopedRoot),scopeVersion,'old tail caused a duplicate write');assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);console.log('PASS certified owner import/copy, edits and create/undo/redo ACK before another canvas tail, then original-source completion preserves content, focus, history and advanced versions');
await evaluate('window.__scopeTextarea=document.activeElement;true');releaseScopeStream();await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`,'original stream did not complete after a scoped ACK');assert.equal(await evaluate('window.__scopeTextarea.isConnected&&document.activeElement===window.__scopeTextarea'),true);assert.equal(await evaluate('window.__scopeTextarea.value'),scopeText);assert.equal(versions.get(scopedRoot),scopeVersion,'old tail caused a duplicate write');assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);console.log('PASS certified owner image/import/copy, edits and create/undo/redo ACK before another canvas tail, then original-source completion preserves content, focus, history and advanced versions');
abandonedRoot=id(840);const backgroundRoot=abandonedRoot;
documents.set(backgroundRoot,createDocument(backgroundRoot,[{id:backgroundRoot,parent:null}],[node('background-node',backgroundRoot,80),node('background-tail',backgroundRoot,450)]));versions.set(backgroundRoot,1);boards.push({id:backgroundRoot,workspace_id:workspace,kind:'board',version:1,title:'Background loading board'});