Files
Endoleap/tests/headless/browser-storage-cases.mjs

1039 lines
193 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 由隔离浏览器 runner 调用的存储/引擎组合场景。IndexedDB、Web Locks、Blob URL 和 Worker 使用浏览器实现,
* 大多数文档/上传服务为内存替身;网络流场景只连接本地可控 fixture,不代表远程 Foundry 数据库验证。
* 持久化场景使用独立 scope/数据库,显示借用等 helper 也可复用传入引擎;run 串行编排,通过断言后才追加结果。
* 版本号需区分:数据库 schema、checkpoint/journal 编码、recovery 镜像、loading 描述和服务协议分别演进。
*/
import {blobCheckpointCase,blockedUpgradeCase} from './blob-checkpoint-browser-cases.mjs';
import {depotFilesCase,depotFilesUpgradeCase,depotAdoptionRaceCase} from './depot-files-browser-cases.mjs';
import {assetAdoptionStorageCase} from './asset-adoption-browser-cases.mjs';
import {assetDepotStorageCase} from './asset-depot-browser-cases.mjs';
import {createBrowserImageDecoder} from '../../src/lib/canvas-runtime/browser-images.ts';
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
import {createBrowserDigests} from '../../src/lib/canvas-runtime/browser-digests.ts';
import {sealRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
import {planDuplicateAsync} from '../../src/lib/canvas-runtime/duplicate.ts';
import {replayHistoryEvidence,sameHistory} from '../../src/lib/canvas-runtime/history-replay.ts';
import { createBrowserStorage } from '../../src/lib/canvas-runtime/storage.ts';
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
import { createDocument, createRegistry, builtinComponents, applyChanges, canvasTransform, applyHistoryTransition } from '../../src/lib/canvas-core/index.ts';
import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts';
import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts';
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
import { createResourceView } from '../../src/lib/canvas-bindings/resource-view.ts';
import { createScopedLoader,loadQueryKey } from '../../src/lib/canvas-runtime/loader.ts';
// 真实流式 fetch 的共享订阅:首块先可见,单个 release 不取消同 query 的其他订阅,错误尾部保留已接收前缀。
async function scopedNetworkLoaderCase(assert) {
// query 身份固定,两个订阅只改变 navigationGeneration;导航代次属于订阅者状态,不应导致重复网络读取。
const scope={accountId:'loader-account',workspaceId:'loader-workspace',projectId:'loader-project',canvasId:'loader-canvas',viewKey:'canvas',format:'fixture-ndjson-v1',queryFingerprint:'all',source:'network-fixture',authEpoch:1,navigationGeneration:1};
// 分别运行合法尾部与非法 JSON 尾部;每轮随机 key 对应 HTTP fixture 的独立挂起流。
async function run(fail) {
const key=crypto.randomUUID();let signal,first;const initial=new Promise(resolve=>{first=resolve;});
// load 使用实际 fetch/ReadableStream;按换行累积并解析 JSON,给每条结果补 query、版本、序号和覆盖片段。
const loader=createScopedLoader({current:()=>true,async *load(query,abort){
signal=abort;const response=await fetch(`/loader-stream?key=${key}`,{signal:abort}),reader=response.body.getReader(),decoder=new TextDecoder();let pending='',sequence=0;
// TextDecoder 保留跨字节块的字符;只有完整一行才 yield,finally 取消 reader,避免残留流读取。
try{while(true){const next=await reader.read();pending+=decoder.decode(next.value??new Uint8Array(),{stream:!next.done});let end;while((end=pending.indexOf('\n'))>=0){const line=pending.slice(0,end);pending=pending.slice(end+1);if(!line)continue;const value=JSON.parse(line);yield{queryKey:loadQueryKey(query),projectId:'loader-project',snapshotToken:key,serverVersions:{'loader-canvas':1},permissionEpoch:query.authEpoch,sequence:sequence++,coverage:[`part-${value.part}`],complete:value.complete,byteLength:new TextEncoder().encode(line).byteLength,value:Object.freeze(value)};}if(next.done)break;}}finally{await reader.cancel().catch(()=>{});}
}});
// a/b 共享一次加载但各自拥有 ready Promise;错误场景提前接住 b 的拒绝,避免未处理 Promise。
const a=loader.acquire(scope,{onUpdate:state=>{if(state.status==='initial-ready')first();}}),b=loader.acquire({...scope,navigationGeneration:2});
const rejected=fail?b.ready.then(()=>{throw Error('invalid tail became complete');},()=>{}):undefined;
try{
// 等待第一行事件,要求两个订阅都进入 initial-ready,不能等流尾才发布首块。
await initial;assert(a.getSnapshot().chunks.length===1&&b.getSnapshot().status==='initial-ready','first network chunk waited for the tail');
// 释放 a 后只有它的 ready 拒绝且状态冻结;共享 fetch 的 AbortSignal 必须仍未中止。
const cancelled=a.ready.then(()=>{throw Error('cancelled subscriber completed');},error=>assert(error.code==='load_aborted','subscriber cancellation changed'));a.release();await cancelled;assert(!signal.aborted,'one subscriber cancelled the shared fetch');
// 经另一 HTTP 请求放行尾部并检查 reads=1;随后分别验证成功完整态或 failed-with-partial。
const response=await(await fetch(`/loader-release?key=${key}${fail?'&fail=1':''}`)).json();assert(response.reads===1,'subscribers fetched the same query twice');
if(fail){await rejected;assert(b.getSnapshot().status==='failed-with-partial'&&b.getSnapshot().chunks.length===1,'invalid network tail discarded or completed the prefix');}
else{const result=await b.ready;assert(result.status==='complete'&&result.chunks.length===2&&result.scope.navigationGeneration===2,'stream completion lost subscriber scope or chunks');assert(a.getSnapshot().status==='cancelled'&&a.getSnapshot().chunks.length===1,'cancelled subscriber observed its peer tail');}
// 两个订阅均幂等释放,再关闭 loader;不把一次订阅取消视作整个共享读取终止。
}finally{a.release();b.release();loader.close();}
}
await run(false);await run(true);
}
// 两个 ResourceView 共享同一显示读取与 Blob URL;逐个释放只减少引用,最后一个释放才撤销 URL 和 display pin。
async function sharedDisplayReadersCase(engine, storage, assert) {
// 保存原 readFile/createObjectURL/revokeObjectURL 以便临时计数并在 finally 恢复,防止影响后续场景。
const resource = engine.getSnapshot().document.elements.get('picture').data.assetId, read = storage.readFile, createURL = URL.createObjectURL, revokeURL = URL.revokeObjectURL;
const revision = engine.getSnapshot().rootRevision, errors = []; let reads = 0, created = 0, revoked = 0, first = new Map(), second = new Map();
// 每个 view 保存自己的 source Map,但 resolve 都借用同一 engine.displayResource。
const a = createResourceView({ resolve: (id, signal) => engine.displayResource(id, { signal }), onChange: sources => { first = sources; }, onError: error => errors.push(error) });
const b = createResourceView({ resolve: (id, signal) => engine.displayResource(id, { signal }), onChange: sources => { second = sources; }, onError: error => errors.push(error) });
// 有界等待各 ResourceView 的异步发布;不是固定 sleep 后假设读取完成。
const wait = async predicate => { for (let i = 0; i < 100; i++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 5)); } throw Error('shared display did not settle'); };
try {
// 包装生产读字节与 URL API,仅计数,不模拟返回内容或省略真实 Blob。
storage.readFile = (...args) => { reads++; return read(...args); }; URL.createObjectURL = bytes => { created++; return createURL.call(URL, bytes); }; URL.revokeObjectURL = url => { revoked++; return revokeURL.call(URL, url); };
a.sync([['picture', resource]]); await wait(() => first.has('picture')); const url = first.get('picture').url;
// 第二视图加入同一资源应共享既有 URL;同一视图再加一处引用也不能重读或替换 handle。
b.sync([['other-view', resource]]); await wait(() => second.has('other-view')); assert(second.get('other-view').url === url && reads === 1 && created === 1, 'two views did not share one IndexedDB read and Blob URL');
a.sync([['picture', resource], ['copy', resource]]); await wait(() => first.has('copy')); assert(first.get('copy').url === url && reads === 1 && created === 1 && revoked === 0, 'adding an image reference replaced existing display handles');
// 释放第一个视图后 URL 仍可 fetch,display pin 仍在;最后一个视图释放才各撤销一次。
a.dispose(); assert(revoked === 0 && (await (await fetch(url)).arrayBuffer()).byteLength > 0, 'closing one view revoked another view image');
assert(engine.getResourcePins().pins.get(resource).has('display'), 'remaining view lost its display pin'); b.dispose(); assert(revoked === 1 && !engine.getResourcePins().pins.get(resource).has('display'), 'last view did not release its URL and display pin');
// 全部借用结束后不保留 idle URL,重新借用需新读取、新 URL,释放后再次撤销。
const fresh = await engine.displayResource(resource); assert(fresh.url !== url && reads === 2 && created === 2, 'released URL was retained as an idle cache'); fresh.release(); assert(revoked === 2, 'fresh handle did not revoke');
// 显示借用仅是资源生命周期,不应新增业务 Root 修订或向观察者报告错误。
assert(errors.length === 0 && engine.getSnapshot().rootRevision === revision, 'display caching changed the business Root or reported a reader failure');
} finally { a.dispose(); b.dispose(); storage.readFile = read; URL.createObjectURL = createURL; URL.revokeObjectURL = revokeURL; }
}
// 真实图片 Worker 将内在尺寸补入原创建历史;尺寸写入成功但 ACK 丢失后经 IndexedDB 重开查询,undo/redo 保持单个创建单元。
async function defaultImageSizeCase(name, assert) {
// 使用真实浏览器存储和解码端口,配置 checkpointEvery=3,使保存可覆盖 checkpoint 和其间的 journal 切点。
const storage = {...createBrowserStorage({ name: `${name}-default-size`, checkpointEvery: 3 })}, images = createBrowserImageDecoder();
const scope = { accountId: 'sizing-account', workspaceId: 'sizing-workspace', projectId: 'sizing-project' }, registry = createRegistry(builtinComponents());
const original = createDocument(scope.projectId, [{ id: 'root', parent: null }], []), receipts = new Map(), writes = [], queries = [], uploads = [];
let server = original, version = 1, next = 0, engine;
// 文档服务是替身:compile 固定版本条件,submit 只对 historyEffect 的尺寸变更丢 ACK,query 查询已保存成功结果。
const persistence = {
compile(intent, id, snapshot) { return { payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: { root: snapshot.runtime.versions.get('root') } }; },
async submit(request) { writes.push(request); const { intent } = JSON.parse(request.body); server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['root', ++version]]) }); return intent.historyEffect ? { outcome: 'unresolved', blocked: true, error: 'lost_size_ack' } : { outcome: 'applied' }; },
async query(request) { queries.push(request); return receipts.has(request.id) ? { outcome: 'applied' } : { outcome: 'unresolved', blocked: true }; },
canonical(request) { return receipts.get(request.id); },
};
// 上传替身报告 4×2 元数据并计数;不调用真实资产服务,异常 query 会使本场景失败。
const assets = { sourceURL: id => `/asset/${id}`, displayURL: id => `/asset/${id}`, async upload(request, bytes) { uploads.push(request);return { outcome: 'applied', canonical: { id: JSON.parse(request.body).resource_id, bytes: bytes.size, mime_type: bytes.type, width: 4, height: 2, url: '/stored' } }; }, async query() { throw Error('unexpected asset query'); }, accept: (_request, status) => status.canonical };
const options = { scope, registry, document: original, versions: new Map([['root', 1]]), storage, images, assets, persistence, createId: () => `size-${++next}` };
try {
// 用 OffscreenCanvas 生成真实 4×2 PNG,defaultSize 允许解码后保持宽 260、调整高 130。
engine = await openProjectEngine(options); const canvas = new OffscreenCanvas(4, 2); canvas.getContext('2d').fillRect(0, 0, 4, 2); const bytes = await canvas.convertToBlob({ type: 'image/png' });
const result = engine.stageFiles([{ name: 'pixel.png', bytes, defaultSize: true }], ([ref], tx) => tx.dispatch({ type: 'element.create', element: { id: 'picture', incarnation: 1, componentId: 'image', dataVersion: 1, structure: 'node', ownerCanvasId: 'root', data: { title: 'Picture', text: '', assetId: ref.id, src: ref.src }, geometry: { x: 0, y: 0, width: 260, height: 200, rotation: 0 } } }));
assert(result.status === 'applied', 'default image admission failed'); await engine.settle();
assert(engine.getSnapshot().document.elements.get('picture').geometry.height === 130 && engine.getSnapshot().history.undo.length === 1, 'decode did not enrich the original creation');
// 定位尺寸 historyEffect 请求并保留 body;关闭后直接读取镜像,确认该历史语义需要 recovery v6。
const request = writes.find(request => JSON.parse(request.body).intent.historyEffect); assert(!!request, 'size change was never submitted'); const body = request.body;
await engine.close(); const owner = await storage.claim(scope), cut = await storage.recover(owner); assert(cut.formatVersion === 6, 'size effect did not require recovery v6'); decodeRecovery(cut, registry); await owner.release();
// 重开只查询原尺寸请求且不得重复 submit,再复用共享显示场景验证读取及 URL 引用计数。
engine = await openProjectEngine(options); await engine.settle();
assert(queries.some(query => query.id === request.id && query.body === body), 'lost size receipt did not query the frozen original request');
assert(writes.filter(write => write.id === request.id).length === 1, 'lost size receipt repeated the write');
await sharedDisplayReadersCase(engine, storage, assert);
// undo 删除已定尺寸创建,redo 以第二代恢复同样尺寸;整个恢复及历史遍历都不能重复上传。
assert(engine.editor.history.undo().status === 'applied', 'sized creation could not undo after reopening'); await engine.settle(); assert(!server.elements.has('picture'), 'undo left the server image behind');
assert(engine.editor.history.redo().status === 'applied', 'sized creation could not redo after reopening'); await engine.settle();
assert(server.elements.get('picture').incarnation === 2 && server.elements.get('picture').geometry.height === 130, 'redo lost size or reused a deleted incarnation'); assert(uploads.length === 1, 'size recovery repeated the upload');
} finally { await engine?.close(); images.close(); storage.close(); indexedDB.deleteDatabase(`${name}-default-size`); }
}
// 包装真实 Worker 记录并发和释放:三个 PNG 解码峰值为二,坏字节或中止都不能留下活跃 Worker。
async function imageDecodeWorkerCase(assert){
// 子类保留原生行为,只在构造成功和首次 terminate 时增减计数;finally 恢复全局构造器。
const NativeWorker=globalThis.Worker;let active=0,peak=0,created=0;globalThis.Worker=class extends NativeWorker{constructor(...args){super(...args);active++;created++;peak=Math.max(peak,active);this.live=true;}terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
// 先实际生成 PNG 并并发解码,再测试伪图片、立即 abort;检查错误码与 active=0,而非只看 Promise 是否结束。
const pool=createBrowserImageDecoder();try{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),results=await Promise.all([pool.decode(bytes),pool.decode(bytes),pool.decode(bytes)]);assert(results.every(size=>size.width===4&&size.height===2),'real bitmap decoder returned incorrect dimensions');assert(peak===2&&active===0&&created===3,'image worker concurrency/cleanup failed');let code;try{await pool.decode(new Blob(['not an image'],{type:'image/png'}));}catch(error){code=error.code;}assert(code==='image_decode_failed'&&active===0,'invalid image did not fail or leaked a worker');const cancel=new AbortController(),pending=pool.decode(bytes,cancel.signal),cancelled=pending.then(()=>{throw Error('cancelled image returned dimensions');},error=>assert(error.code==='image_decode_aborted','image cancellation code changed'));cancel.abort();await cancelled;assert(active===0,'cancelled image worker leaked');}finally{pool.close();globalThis.Worker=NativeWorker;}
}
// 真实摘要 Worker 与原生 SHA-256 对照,验证排队取消不启动第三个 Worker,关闭池后不允许迟到摘要发布。
async function digestWorkerCase(assert){
const NativeWorker=globalThis.Worker;let active=0,peak=0,created=0;const replies=[];
// 记录每个 Worker 的消息,用 reply.version/byteLength 证明结果确实经过 Worker 协议,而非主线程假返回。
globalThis.Worker=class extends NativeWorker{constructor(...args){super(...args);created++;active++;peak=Math.max(peak,active);this.live=true;this.addEventListener('message',event=>replies.push(event.data));}terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
const pool=createBrowserDigests();try{
// 四 MiB Blob 交给两个活跃任务和一个已排队任务;排队任务中止后只应创建前两个 Worker。
const bytes=new Blob([new Uint8Array(4*1024*1024).fill(97)]),expected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join(''),cancel=new AbortController(),a=pool.digest(bytes),b=pool.digest(bytes),queued=pool.digest(bytes,cancel.signal),cancelled=queued.then(()=>{throw Error('queued digest ran');},error=>assert(error.code==='import_aborted','queued digest cancellation changed'));
// 等待取消与成功任务后核对摘要、并发峰值和协议回复;正常结束应终止所有 Worker。
cancel.abort();await cancelled;const results=await Promise.all([a,b]);assert(results.every(value=>value===expected),'worker digest differs from native SHA256');assert(peak===2&&created===2&&active===0,'worker pool exceeded bounds or leaked a worker');assert(replies.length===2&&replies.every(reply=>reply.version===1&&reply.byteLength===bytes.size),'worker protocol did not actually run');
// 另开一项后立即 close;被关闭工作应拒绝 import_aborted,不能留下 Worker 或成功发布。
const late=pool.digest(bytes),closed=late.then(()=>{throw Error('closed worker published');},error=>assert(error.code==='import_aborted','pool close did not cancel work'));await Promise.resolve();pool.close();await closed;assert(active===0,'pool close retained a worker');
}finally{pool.close();globalThis.Worker=NativeWorker;}
}
// 异步 checksum 期间仍需持有输入所有权:可变镜像/文件描述先复制,可信 sealed 镜像可复用,浅层 Object.freeze 不能冒充所有权。
async function ownedImageStorageCase(name,assert){
const storage=createBrowserStorage({name:`${name}-owned-image`}),scope={accountId:'owner',workspaceId:'workspace',projectId:'project'};let leader;
try{
leader=await storage.claim(scope);const payload={nested:{title:'captured'}},descriptor={id:'captured-file',bytes:new Blob(['captured bytes'])},raw={formatVersion:1,rootRevision:0,localRevision:0,payload};
// 调用 commit 后立刻改原 payload 和文件描述;实际恢复只能看到调用时标题、ID 和字节。
const first=storage.commit(leader,{image:raw,files:[descriptor]});payload.nested.title='changed';descriptor.id='changed-file';descriptor.bytes=new Blob(['changed bytes']);await first;
assert((await storage.recover(leader)).payload.nested.title==='captured','unowned image changed while checksum was pending');assert(await(await storage.readFile(leader,'captured-file')).text()==='captured bytes','file descriptor changed while checksum was pending');assert(await storage.readFile(leader,'changed-file')===undefined,'late descriptor identity leaked into storage');
// 生产 sealRecoveryImage 的可信镜像也必须经过真实 IndexedDB/checksum 并可恢复。
const sealed=sealRecoveryImage(1,1,1,{nested:{title:'sealed'}});await storage.commit(leader,{image:sealed});assert((await storage.recover(leader)).payload.nested.title==='sealed','sealed image did not survive IndexedDB/checksum');
// 只冻结外壳的伪 sealed 对象仍有可变嵌套数据,必须防御复制,不能借 Object.isFrozen 绕过捕获。
const nested={title:'before'},impostor=Object.freeze({formatVersion:1,rootRevision:2,localRevision:1,payload:{nested}}),pending=storage.commit(leader,{image:impostor});nested.title='after';await pending;assert((await storage.recover(leader)).payload.nested.title==='before','a shallow-frozen impostor bypassed the defensive copy');
}finally{await leader?.release();storage.close();indexedDB.deleteDatabase(`${name}-owned-image`);}
}
// 复制规划必须让出到真实浏览器任务队列;1500 项源中只选一个,也要保持选择范围、源文档和取消语义。
async function cooperativeCopyCase(assert) {
// 大源迫使索引/扫描跨切片,而不是靠一次简单复制碰巧异步完成。
const registry=createRegistry(builtinComponents()),source=createDocument('copy-project',[{id:'copy-root',parent:null}],Array.from({length:1500},(_,index)=>({id:`source-${index}`,incarnation:1,structure:'node',ownerCanvasId:'copy-root',componentId:'text',dataVersion:1,data:{title:`item ${index}`,text:''},geometry:{x:index,y:0,width:100,height:100,rotation:0}})));
let next=0,timerRan=false;
// 零延迟 timer 是独立浏览器任务,只有真正让出主线程而非仅 Promise 微任务才能让它执行。
const timer=setTimeout(()=>{timerRan=true;},0);
try {
// maxStepsPerSlice=512 后 timer 应先运行;只选一项仍只产生一条变化,源元素数保持不变。
const plan=await planDuplicateAsync(source,registry,[{id:'source-0',incarnation:1}],{createId:()=>`copy-${++next}`},{maxStepsPerSlice:512});
assert(timerRan,'copy planning starved a real browser task');assert(plan.changes.length===1,'copy planning changed selection scope');assert(source.elements.size===1500,'copy planning mutated its source');
// 再用更小切片和 timer.abort,要求规划拒绝 copy_aborted;finally 清除两种测试定时器。
const controller=new AbortController(),cancel=setTimeout(()=>controller.abort(),0);
try { let code;try { await planDuplicateAsync(source,registry,[{id:'source-0',incarnation:1}],{createId:()=>`unused-${++next}`},{signal:controller.signal,maxStepsPerSlice:128}); } catch(error){code=error.code;}assert(code==='copy_aborted','browser cancellation did not stop planning'); } finally {clearTimeout(cancel);}
} finally {clearTimeout(timer);}
}
// 恢复审计被后续恢复再次消费时,字段链和结构链都要保持旧请求事实、最新审阅基线及新 incarnation,不把审计只当存档。
async function adoptionConsumerCase(name,assert){
// 分别测试输入分组的多次替代,以及分区创建后丢弃成员修改,两条分支有不同审计层数和 POST 次数。
for(const structural of [false,true]){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),database=`${name}-adoption-consumer-${structural}`,storage=createBrowserStorage({name:database}),calls=[],saved=new Map();let document=original,versions=new Map([[root,1]]),reject=true,engine;
// 局部模型按参与 canvas 过滤元素,完整读覆盖全部;已移除 canvas 单独列入 removedCanvasIds。
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
// 默认明确拒绝写入;GET 提供当前完整基线,放行后逐参与版本检查并生成覆盖全部逻辑 ID 的回执。
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'adoption consumer lost a version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
// selected 从当前未解决逻辑操作生成 keep-local 选项,后续会显式改一个为 discard-local。
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})},selected=()=>new Map([...engine.getSnapshot().runtime.ledger.operations].filter(([,record])=>record.phase!=='resolved').map(([id])=>[id,'keep-local']));
// 每次关闭前保存所有已见冻结 body;再次看到同一请求必须字节相同,再重开真实浏览器存储。
const reopen=async()=>{for(const[id,request]of engine.getSnapshot().runtime.ledger.requests){if(saved.has(id))assert(saved.get(id)===request.body,'an earlier recovery request was rewritten');else saved.set(id,request.body);}await engine.close();engine=await openProjectEngine(options);};
try{
engine=await openProjectEngine(options);
// 结构分支先创建 frame/canvas/member 再编辑 member;字段分支把 one/two 记入同一输入组。
if(structural){engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('frame'),componentId:'frame'}});tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'frame'}}});tx.dispatch({type:'element.create',element:node('member',child)});});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'member',incarnation:1},patch:{title:'discard member edit'}});}
else{engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'one'}},{groupId:'chain'});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'two'}},{groupId:'chain'});}
// 第一次恢复后重开,再外部推进根标题和版本,构造后续恢复必须消费旧审计的条件。
await engine.settle();let session=await engine.prepareRecovery(),first=session.plan(selected());await session.resolve(first);await engine.settle();await reopen();document=applyChanges(document,[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'remote 1',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1);
// 第二次选择丢弃一个替代操作;结构分支此时放行发送,字段分支还会增加本地编辑和第三次审阅。
session=await engine.prepareRecovery();const decisions=selected();decisions.set(first.operationIds[structural?1:0],'discard-local');if(structural)reject=false;const second=session.plan(decisions);await session.resolve(second);await engine.settle();await reopen();
// 字段分支检查三层审计、远程基线 undo 和合批;结构分支检查两层审计、整体 undo/redo 与第二代身份。
if(!structural){engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'after adoption'}},{groupId:'chain'});await engine.settle();document=applyChanges(document,[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'remote 1',after:'remote 2',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1);session=await engine.prepareRecovery();const third=session.plan(selected());reject=false;await session.resolve(third);await engine.settle();await reopen();assert(engine.getSnapshot().runtime.recoveries.size===3,'nested adoption audit lineage was lost');assert(document.elements.get('a').data.title==='two'&&document.elements.get('a').data.text==='after adoption','post-adoption native edit was lost');assert(engine.editor.history.undo().status==='applied','nested field recovery undo failed');await engine.settle();assert(document.elements.get('a').data.title==='remote 2'&&document.elements.get('a').data.text==='','undo did not restore newest reviewed server baseline');assert(engine.editor.history.redo().status==='applied','nested field recovery redo failed');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===6,'nested field recovery did not batch its replacements');}
else{assert(engine.getSnapshot().runtime.recoveries.size===2&&document.elements.get('member').data.title==='member','structural choice did not survive adoption');assert(engine.editor.history.undo().status==='applied','adopted structure undo failed');await engine.settle();assert(!document.canvases.has(child),'adopted structure undo left a child canvas');assert(engine.editor.history.redo().status==='applied','adopted structure redo failed');await engine.settle();assert(document.elements.get('member').incarnation===2&&document.elements.get('a').data.title==='remote 1','structural redo reused a life or erased remote data');assert(calls.filter(call=>call.method==='POST').length===5,'structural adoption repeated a request');}
// 最后再重开,逐个比对历次保存的 body,保证重规划没有改写任何历史请求。
await reopen();for(const[id,body]of saved)assert(engine.getSnapshot().runtime.ledger.requests.get(id).body===body,'original frozen request bytes changed after final reopen');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
}
// 几何与内容混合输入组经过 keep/discard 后,持久化的 historyReplay 必须从旧备份精确重建新历史,并且不泄漏到 wire。
async function adoptionEvidenceCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},original=createDocument(root,[{id:root,parent:null}],[node]),database=`${name}-adoption-evidence`,storage=createBrowserStorage({name:database}),calls=[];let document=original,version=1,reject=true,engine;
// 单根项目的完整或局部模型由同一服务状态生成;区分完整读与替代请求 ACK。
const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:Object.fromEntries(document.tombstones),versions:{[root]:version}});
// 请求体不得包含 historyReplay;拒绝时不写入,放行后核对版本并应用有序操作。
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);assert(!request.body.includes('historyReplay'),'local history evidence leaked into wire payload');if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};assert(body.expected_versions[root]===version,'adoption evidence changed the version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);version++;return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
// 同组先改宽度,再加入待丢弃标题和保留标题;恢复只让选中的两个贡献进入历史重放。
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}},{groupId:'adoption'});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0],discard=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'discard me'}},{groupId:'adoption'});engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'kept'}},{groupId:'adoption'});await engine.settle();const session=await engine.prepareRecovery(),selected=new Map(session.pendingOperationIds.map(id=>[id,id===discard.operationIds[0]?'discard-local':'keep-local'])),plan=session.plan(selected);reject=false;await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options);
// 从审计备份与 historyReplay 重建历史再比较;核对新逻辑 ID 共用一个请求,以及分组 undo 回到原宽度和标题。
const snapshot=engine.getSnapshot(),audit=snapshot.runtime.recoveries.get(plan.id),backup=audit.backup.history,before={undo:backup.undo,redo:backup.redo,entries:new Map(backup.entries)},reconstructed=replayHistoryEvidence(before,audit.historyReplay,id=>snapshot.operations.has(id));assert(sameHistory(reconstructed,snapshot.history),'persisted adoption witness did not compose to its history');assert(audit.historyReplay.events.filter(event=>event.kind==='operation').length===2,'discarded contribution became a replay operation');assert(snapshot.runtime.ledger.requests.get(old.id).body===old.body,'adoption evidence changed old request bytes');assert(document.elements.get('a').geometry.width===250&&document.elements.get('a').data.title==='kept','adoption content or geometry changed');const replacementRequests=calls.filter(call=>call.method==='POST'&&plan.operationIds.every(id=>JSON.parse(call.body).operations.some(operation=>operation.id===id)));assert(replacementRequests.length===1,'adopted logical IDs did not share one frozen request');assert(engine.editor.history.undo().status==='applied','geometry evidence broke undo');await engine.settle();assert(document.elements.get('a').geometry.width===200&&document.elements.get('a').data.title==='a','adopted group inverse is incorrect');assert(engine.editor.history.redo().status==='applied','geometry evidence broke redo');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===4,'adoption evidence did not use one replacement batch');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
// 较早 undo/redo 被拒绝而后续遍历已确认时,恢复只能重放历史证据,不能重发已确认内容;生命周期映射还要传给更早/剩余历史。
async function confirmedTraversalCase(name,assert){
// undo 和 redo 分别构造已确认恢复新生命期的场景,避免只验证普通字段回退。
for(const direction of ['undo','redo']){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),database=`${name}-confirmed-${direction}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine;
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
// 服务替身按各 canvas 版本条件接纳写入,拒绝开关只影响指定步骤,已确认请求保留原始事实。
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'confirmed traversal lost its version condition');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
// 辅助编辑始终取服务文档的当前 incarnation,避免 fixture 自己误用旧代次。
const edit=async(id,text)=>{const result=engine.editor.dispatch({type:'element.data',target:{id,incarnation:document.elements.get(id).incarnation},patch:{text}});assert(result.status==='applied','fixture edit failed');await engine.settle();};
try{
engine=await openProjectEngine(options);await edit('a','edited a');
// undo 方向先删除 a 并编辑另一 canvas;redo 方向先创建/编辑 new,再撤销三步建立 redo 链。
if(direction==='undo'){engine.editor.dispatch({type:'element.remove',target:{id:'a',incarnation:1}});await engine.settle();await edit('c','edited c');}
else{engine.editor.dispatch({type:'element.create',element:node('new',child)});await engine.settle();await edit('new','edited new');for(let i=0;i<3;i++){engine.editor.history.undo();await engine.settle();}}
// 先拒绝一次遍历,再允许下一次确认,然后重开,检查未决前驱与已确认后继能共同恢复。
reject=true;const first=engine.editor.history[direction]();await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(first.operationIds[0]));reject=false;const confirmed=engine.editor.history[direction]();await engine.settle();const applied=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(confirmed.operationIds[0]));await engine.close();engine=await openProjectEngine(options);
// 丢弃未决前驱会违反已确认遍历顺序,必须拒绝;保留它后检查原请求字节与已确认 POST 次数不变。
const session=await engine.prepareRecovery();let denied=false;try{session.plan(new Map([[first.operationIds[0],'discard-local']]));}catch(error){denied=error.code==='recovery_history_traversal_order';}assert(denied,'confirmed traversal silently skipped a discarded predecessor');const plan=session.plan(new Map([[first.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body&&engine.getSnapshot().runtime.ledger.requests.get(applied.id).body===applied.body,'history reconstruction rewrote original request facts');assert(calls.filter(call=>call.method==='POST'&&call.requestId===applied.id).length===1,'confirmed traversal was submitted again');
// 后续继续操作更早/剩余历史,检查第二代再到第三代映射和各分支精确写次数,防止恢复重复派发。
if(direction==='undo'){assert(document.elements.get('a').incarnation===2&&document.elements.get('c').data.text==='','confirmed undo restoration was lost');assert(engine.editor.history.undo().status==='applied','older remapped history was stale');await engine.settle();assert(document.elements.get('a').data.text==='','older history inverse used the wrong lifecycle');assert(engine.editor.history.redo().status==='applied','older history redo failed');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===8,'confirmed undo recovery duplicated a write');}
else{assert(document.elements.get('new').incarnation===2,'confirmed redo incarnation was lost');assert(engine.editor.history.redo().status==='applied','remaining redo entry was not remapped');await engine.settle();for(let i=0;i<3;i++){assert(engine.editor.history.undo().status==='applied','post-recovery undo failed');await engine.settle();}for(let i=0;i<3;i++){assert(engine.editor.history.redo().status==='applied','post-recovery redo failed');await engine.settle();}assert(document.elements.get('new').incarnation===3&&document.elements.get('new').data.text==='edited new','later traversal reused an old lifecycle');assert(calls.filter(call=>call.method==='POST').length===16,'confirmed redo recovery duplicated a write');}
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
}
// 同一用户输入组横跨两个 canvas:先前 pending 贡献可保留或丢弃,但后来已确认贡献必须保留逻辑身份和分组撤销能力。
async function userJournalRecoveryCase(name,assert){
// 分别覆盖 keep-local 与 discard-local,两者恢复后仍各只有一个 undo 单元。
for(const keep of [true,false]){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),database=`${name}-user-journal-${keep}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=true,engine;
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
// 完整读与条件写共用内存服务,按参与 canvas 推进版本,检查恢复没有重传已确认 c 的编辑。
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'group recovery lost its version condition');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
// 先拒绝 a 的同组文字,再确认 c 的同组文字;重开后只把未解决 a 交给用户选择。
engine=await openProjectEngine(options);const first=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'pending first'}},{groupId:'shared'});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0];reject=false;const confirmed=engine.editor.dispatch({type:'element.data',target:{id:'c',incarnation:1},patch:{text:'confirmed later'}},{groupId:'shared'});await engine.settle();await engine.close();engine=await openProjectEngine(options);
// 恢复规划不能把已经成功的 c 当作 replacement 重新发送;恢复和下一次重开之间仍使用真实 IndexedDB。
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],keep?'keep-local':'discard-local']]));await session.resolve(plan);await engine.settle();assert(calls.filter(call=>call.method==='POST'&&JSON.parse(call.body).operations.some(operation=>operation.changes.some(change=>change.kind==='field'&&change.target.id==='c'))).length===1,'confirmed group contribution was sent again');await engine.close();engine=await openProjectEngine(options);
// 组中必须保留 confirmed 的 operationId;undo/redo 组合选中贡献,丢弃 a 时不得把它重新带回。
const history=engine.getSnapshot().history;assert(history.undo.length===1&&history.entries.values().next().value.operationIds.includes(confirmed.operationIds[0]),'group lost its confirmed logical fact');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'group replan changed rejected bytes');assert(document.elements.get('a').data.text===(keep?'pending first':''),'group prefix choice was ignored');assert(engine.editor.history.undo().status==='applied','group undo failed');await engine.settle();assert(document.elements.get('a').data.text===''&&document.elements.get('c').data.text==='','group undo did not restore both selected contributions');assert(engine.editor.history.redo().status==='applied','group redo failed');await engine.settle();assert(document.elements.get('a').data.text===(keep?'pending first':'')&&document.elements.get('c').data.text==='confirmed later','group redo lost selected content');assert(calls.filter(call=>call.method==='POST').length===(keep?5:4),'group recovery repeated a write');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
}
// 被拒绝 undo 后出现已确认新分支,redo 已失效;恢复重放要保留分支而不是按旧栈快照复活 redo。
async function journalRecoveryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),storage=createBrowserStorage({name:`${name}-journal-recovery`}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine;
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
// 按每个参与 canvas 校验版本并回执;不同 canvas 的后续分支可确认,不代表早先 undo 已确认。
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'journal recovery lost its version condition');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
// 先确认 a 的编辑,再拒绝其 undo,随后在 c 上创建已确认新分支,并核对 redo 已清空。
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'edited'}});await engine.settle();reject=true;const undo=engine.editor.history.undo();await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(undo.operationIds[0]));reject=false;const branch=engine.editor.dispatch({type:'element.data',target:{id:'c',incarnation:1},patch:{text:'confirmed branch'}});await engine.settle();assert(engine.getSnapshot().history.redo.length===0,'branch did not invalidate redo');await engine.close();engine=await openProjectEngine(options);
// 仅重规划未决 undo;重开后应是 a 已撤销、c 分支仍在,分支 ID 和旧请求 body 不变,再验证分支 undo/redo。
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[undo.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(document.elements.get('a').data.text===''&&document.elements.get('c').data.text==='confirmed branch','confirmed branch was lost or pending undo was not restored');assert(engine.getSnapshot().history.undo.length===1&&engine.getSnapshot().history.redo.length===0,'journal recovery retained an invalidated redo');assert(engine.getSnapshot().history.entries.values().next().value.operationIds.includes(branch.operationIds[0]),'confirmed branch logical identity changed');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'journal adoption changed the rejected request');assert(engine.editor.history.undo().status==='applied','confirmed branch undo failed');await engine.settle();assert(document.elements.get('c').data.text==='','branch inverse used an incorrect baseline');assert(engine.editor.history.redo().status==='applied','confirmed branch redo failed');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===6,'confirmed content was sent twice during event replay');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-journal-recovery`);}
}
// historyTransition 必须记录 redo 被新分支清除前的证据;重开后可逆推旧栈,但逆推元数据不能修改业务文档或进入协议请求。
async function historyEvidenceCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument(root,[{id:root,parent:null}],[node]),storage=createBrowserStorage({name:`${name}-history-evidence`}),calls=[];let engine;
// 全部写入明确拒绝,后续依赖保持 pending;这样只产生一次原请求,便于发现证据是否误入 wire。
const transport=async request=>{calls.push(request);return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};};
// value 对 entry 表按 ID 排序,比较历史内容而非 Map 的插入次序。
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})},value=history=>JSON.stringify({undo:history.undo,redo:history.redo,entries:[...history.entries].sort(([a],[b])=>a.localeCompare(b))});
try{
// 编辑→undo→新分支依次保存栈状态;分支应立即清空 redo,再关闭引擎触发恢复。
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'first'}});await engine.settle();const first=engine.getSnapshot().history,undo=engine.editor.history.undo();await engine.settle();const beforeBranch=engine.getSnapshot().history,branch=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'branch'}});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(engine.getSnapshot().history.redo.length===0,'new branch did not clear redo');await engine.close();
// 逆用分支 transition 恢复 beforeBranch,再逆 undo 恢复 first;业务文字仍为 branch,原请求只发一次。
engine=await openProjectEngine(options);const snapshot=engine.getSnapshot(),transition=snapshot.operations.get(branch.operationIds[0]).historyTransition,restored=applyHistoryTransition(snapshot.history,transition,'reverse');assert(value(restored)===value(beforeBranch),'cleared redo evidence did not survive IndexedDB');const baseline=applyHistoryTransition(restored,snapshot.operations.get(undo.operationIds[0]).historyTransition,'reverse');assert(value(baseline)===value(first),'undo stack transition could not be reconstructed after reopening');assert(snapshot.document.elements.get('a').data.text==='branch','metadata reconstruction mutated the document');assert(snapshot.runtime.ledger.requests.get(old.id).body===old.body&&calls.length===1,'evidence capture changed or repeated the old rejected request');assert(calls.every(call=>!String(call.body).includes('historyTransition')),'local evidence leaked into protocol 2');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-history-evidence`);}
}
// 分区删除的 undo 已在本地恢复归档结构但被服务拒绝;显式恢复后仍保留 redo 方向,之后每次还原都分配新 incarnation。
async function traversalRecoveryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
// 分区成员带几何和身份,原模型同时有无关 anchor,检查恢复不覆盖远程标题。
const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:300,height:200,rotation:0},data:{title:id,text:''}});
const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('anchor'),{...node('entry'),componentId:'frame'},node('member',child)]),storage=createBrowserStorage({name:`${name}-traversal`}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine;
// 参与集合包括已归档 canvas 的版本及 removedCanvasIds,不能因当前无 canvas 就丢掉版本条件。
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
// 拒绝 undo 时不改模型;恢复成功按已审阅版本重建结构并回执参与 canvas。
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'traversal restoration lost an archive version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
// 先删除入口并确认,再拒绝 undo;另一次远程 anchor 标题修改制造新审阅基线。
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.remove',target:{id:'entry',incarnation:1}});await engine.settle();reject=true;const undo=engine.editor.history.undo();await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(undo.operationIds[0]));
document=applyChanges(document,[{kind:'field',target:{id:'anchor',incarnation:1},field:'data.title',before:'anchor',after:'remote',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1);
// keep-local 恢复的是原 undo 意图而非新建普通用户编辑,重开后仍应在 redo 栈。
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[undo.operationIds[0],'keep-local']]));reject=false;await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options);
assert(engine.getSnapshot().history.redo.length===1&&engine.getSnapshot().history.undo.length===0,'recovered undo did not retain its stack transition');assert(document.elements.get('member').incarnation===2,'restoration used the deleted incarnation');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'rejected undo bytes changed');
// redo 再归档,下一次 undo 用第三代成员;再次重开验证结构和历史处于同一持久化切点。
assert(engine.editor.history.redo().status==='applied','redo after recovered undo failed');await engine.settle();assert(!document.canvases.has(child),'redo did not archive the restored child');assert(engine.editor.history.undo().status==='applied','next lifecycle undo failed');await engine.settle();assert(document.elements.get('member').incarnation===3,'next undo did not advance incarnation');assert(document.elements.get('anchor').data.title==='remote','traversal recovery erased remote content');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===3&&engine.getSnapshot().history.redo.length===1,'restored document and history were not in one durable cut');assert(calls.filter(call=>call.method==='POST').length===5,'traversal recovery duplicated a write');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-traversal`);}
}
// 同组已确认入口与待处理分区/成员混合时,恢复保留一个完整 undo 单元,不能替换已确认逻辑事实或丢失原拒绝请求。
async function mixedHistoryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:300,height:200,rotation:0},data:{title:id,text:''}});
const original=createDocument(root,[{id:root,parent:null}],[node('anchor')]),storage=createBrowserStorage({name:`${name}-mixed-history`}),calls=[];let document=original,versions=new Map([[root,1]]),reject=false,engine;
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
// 新 canvas 的缺省版本为 0;局部 ACK 同时覆盖参与父子 canvas,便于检查整体结构事务。
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'mixed history lost its version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
// 先独立确认 frame,但以相同 groupId 把后续 canvas/member 事务加入一组。
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.create',element:{...node('entry'),componentId:'frame'}},{groupId:'partition'});await engine.settle();const acknowledged=[...engine.getSnapshot().runtime.ledger.operations.keys()][0];
// 拒绝分区后另行改远程 anchor;keep-local 只替代未确认部分,入口的 applied operationId 保留。
reject=true;const edit=engine.editor.transaction(tx=>{tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member',child)});},{groupId:'partition'});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(edit.operationIds[0]));
document=applyChanges(document,[{kind:'field',target:{id:'anchor',incarnation:1},field:'data.title',before:'anchor',after:'remote',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1);
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));reject=false;await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options);
// 恢复重开后仍一个分组;undo 连已确认入口一并移除,redo 的入口和成员都用第二代且保留远程数据。
assert(engine.getSnapshot().history.undo.length===1,'mixed group split or lost its acknowledged prefix');assert(engine.getSnapshot().history.entries.values().next().value.operationIds.includes(acknowledged),'applied logical fact was replaced');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'old rejected bytes changed');
assert(engine.editor.history.undo().status==='applied','mixed structural undo failed after reopening');await engine.settle();assert(!document.elements.has('entry')&&!document.canvases.has(child),'mixed undo left its acknowledged entry');assert(document.elements.get('anchor').data.title==='remote','mixed undo erased remote data');
assert(engine.editor.history.redo().status==='applied','mixed structural redo failed');await engine.settle();assert(document.elements.get('entry').incarnation===2&&document.elements.get('member').incarnation===2,'mixed redo reused deleted identities');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===2,'mixed history restoration did not survive another reopen');assert(calls.filter(call=>call.method==='POST').length===5,'mixed recovery duplicated a write');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-mixed-history`);}
}
// 在旋转且 contentScale 不同的容器间移动分区,靠入口 unitScale 归一保持后代世界 transform,不直接改后代字体数据。
async function scaledContainerCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),target=crypto.randomUUID(),deep=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
// 构造两种父分区缩放与更深一层分区;节点局部几何和 fontSize 保留为检查基准。
const node=(id,owner,kind='text',scale=1)=>({id,incarnation:1,structure:'node',componentId:kind,dataVersion:1,ownerCanvasId:owner,geometry:{x:id==='g'?700:100,y:80,width:300,height:200,rotation:id==='g'?70:30},data:{title:id,text:'body',fontSize:17,...(kind==='frame'?{presentation:{contentScale:scale}}:{})}});
const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'f'}},{id:target,parent:{canvasId:root,entryElementId:'g'}},{id:deep,parent:{canvasId:child,entryElementId:'h'}}],[node('f',root,'frame',.75),node('g',root,'frame',2.5),node('h',child,'frame',.8),node('a',deep)]),storage=createBrowserStorage({name:`${name}-units`}),calls=[];let document=original,versions=new Map([...original.canvases.keys()].map(id=>[id,1])),engine;
// 移动及 undo/redo 各按原子条件请求应用全部变化,检查参与 canvas 版本一致再递增。
const transport=async request=>{calls.push(request);const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'scaled move lost version ordering');for(const operation of body.operations)document=applyChanges(document,operation.changes);const scopes=Object.keys(body.expected_versions);for(const id of scopes)versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>scopes.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>scopes.includes(element.ownerCanvasId)),tombstones:{},versions:Object.fromEntries(scopes.map(id=>[id,versions.get(id)]))}}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
// 逐层比较 x/y/rotation/scale 浮点容差,并核对 fontSize 仍为 17;在移动、重开和遍历后重复调用。
const check=()=>{for(const id of [child,deep]){const a=canvasTransform(engine.getSnapshot().document,id),b=canvasTransform(original,id);for(const key of ['x','y','rotation','scale'])assert(Math.abs(a[key]-b[key])<1e-7,'descendant transform changed: '+key);}assert(engine.getSnapshot().document.elements.get('a').data.fontSize===17,'subtree typography data was rewritten');};
// 移动后期望 unitScale=.4;undo 恢复字段原本缺失,redo 再归一,三次写入不能多出补偿任务。
try{engine=await openProjectEngine(options);assert(engine.editor.dispatch({type:'element.move',target:{id:'f',incarnation:1},canvasId:target}).status==='applied','scaled container move failed');await engine.settle();check();assert(Math.abs(engine.getSnapshot().document.elements.get('f').data.presentation.unitScale-.4)<1e-9,'entry unit ratio was not stored');await engine.close();engine=await openProjectEngine(options);check();assert(engine.editor.history.undo().status==='applied','unit-normalized undo failed');await engine.settle();check();assert(engine.getSnapshot().document.elements.get('f').data.presentation.unitScale===undefined,'undo did not restore absent units');assert(engine.editor.history.redo().status==='applied','unit-normalized redo failed');await engine.settle();check();assert(calls.filter(call=>call.method==='POST').length===3,'unit conversion scheduled duplicate writes');await engine.close();engine=await openProjectEngine(options);check();
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-units`);}
}
// frame/canvas/member 原子创建被拒绝后,恢复重规划完整 owner 树;原请求不可改写,undo 归档与 redo 新生命期均须重开可恢复。
async function structureRecoveryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),storage=createBrowserStorage({name:`${name}-structure`}),calls=[];let document=original,versions=new Map([[root,1]]),reject=true,engine;
// 局部模型同时保留移除 canvas 列表,供结构创建和归档回执使用。
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
// 首次拒绝并推进根版本,后续按审阅后的条件接纳;新 canvas 尚无版本时以 0 校验。
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject){reject=false;versions.set(root,2);return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};}for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'structural request version mismatch');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
// 一个本地事务创建完整分区,再 keep-local 生成替代操作;不能拆开发布无入口或无 owner 的中间结构。
try{engine=await openProjectEngine(options);const edit=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('frame'),componentId:'frame'}});tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'frame'}}});tx.dispatch({type:'element.create',element:node('member',child)});});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();await engine.close();
// 重开后验证 owner 关系与原体,undo 不留孤立 canvas,redo 的成员为第二代并在再次重开后保留。
engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').ownerCanvasId===child,'structural replan did not recover its owner tree');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'old structural request was changed');assert(engine.editor.history.undo().status==='applied','structural undo failed');await engine.settle();assert(!document.canvases.has(child),'undo left an orphan canvas');assert(engine.editor.history.redo().status==='applied','structural redo failed');await engine.settle();assert(document.elements.get('member').incarnation===2,'redo reused a deleted lifecycle');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===2,'restored tree/history lifecycle did not survive reopening');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-structure`);}
}
// 成功回执已保存但 canonical 协调器暂时不可解释时,保留 applied/waiting;恢复先承认成功,再把后续几何重规划到新审阅基线。
async function appliedReceiptRecoveryCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1};
const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}};
const original=createDocument(root,[{id:root,parent:null}],[element]),storage=createBrowserStorage({name:`${name}-applied-receipt`}),calls=[],receipts=new Map();let server=original,version=1,engine,broken=true;
const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:version}});
// 服务实际应用 changes 并按请求 ID 保存成功回执;GET 可取完整模型或原请求结果。
const transport=async request=>{
calls.push(request);if(request.method==='GET')return{status:200,body:request.path.endsWith('/model')?{model:model(true)}:receipts.get(request.path.split('/').at(-1))};
const body=JSON.parse(request.body);assert(body.expected_versions[root]===version,'write did not use reviewed version');for(const operation of body.operations)server=applyChanges(server,operation.changes);version++;
const receipt={protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)};receipts.set(request.requestId,receipt);return{status:200,body:receipt};
};
// 只在 canonical 解释处注入故障,不能把已成功的服务执行改成 not-applied。
const base=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport}),persistence={...base,canonical(...args){if(broken)throw Error('codec_temporarily_unavailable');return base.canonical(...args);}};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence};
try{
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target,patch:{title:'first'}});await engine.settle();
// 原请求虽然 outcome=applied,phase 仍 waiting;后续本地移动可见但暂不能越过未完成协调的 writer。
const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(request.outcome==='applied'&&request.phase==='waiting','failed coordination lost the successful receipt');
const edit=engine.editor.dispatch({type:'element.geometry',target,patch:{x:200}});await engine.settle();await engine.close();
// 模拟引擎关闭后远程把 x 改为 500、width 改为 400,恢复不能把旧回执当最新全量模型。
server=applyChanges(server,[{kind:'field',target,field:'geometry',before:element.geometry,after:{...element.geometry,x:500,width:400}}]);version++;
// 会话 acknowledgedRequestIds 包含已成功请求,pending 只应剩后来几何修改,不能给用户重执行成功操作的选项。
engine=await openProjectEngine(options);const session=await engine.prepareRecovery();assert(session.acknowledgedRequestIds.includes(request.id),'saved applied receipt was not recovered');assert(session.pendingOperationIds.length===1&&session.pendingOperationIds[0]===edit.operationIds[0],'already executed operation was offered for local re-execution');
// 只替代待处理几何,修复解释器后一次解决成功回执与重规划;两次 POST 分别是原写和新替代写。
const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));broken=false;await session.resolve(plan);await engine.settle();assert(calls.filter(call=>call.method==='POST').length===2,'successful original operation was sent again');
assert(engine.getSnapshot().runtime.ledger.requests.get(request.id).body===request.body,'original frozen receipt identity changed');await engine.close();
engine=await openProjectEngine(options);assert(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase==='resolved','acknowledgement and rebased document did not recover together');assert(engine.getSnapshot().document.elements.get('a').geometry.width===400,'old receipt rewound newer geometry');
// 重开后 width 保留远程 400,undo 本地几何只回到审阅的 x=500。
assert(engine.editor.history.undo().status==='applied','rebased geometry history did not recover');await engine.settle();assert(engine.getSnapshot().document.elements.get('a').geometry.x===500,'undo did not return to the reviewed geometry');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-applied-receipt`);}
}
// 冲突恢复创建新 key/body 并保留旧拒绝,替代写成功但响应丢失时从 IndexedDB 查询原替代请求;undo 返回审阅的远程值。
async function conflictReplanCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1};
const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}};
const original=createDocument(root,[{id:root,parent:null}],[element]),storage=createBrowserStorage({name:`${name}-conflict-replan`}),calls=[],receipts=new Map();let server=original,version=1,engine,reject=true,lose=true;
const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:version}});
// 首次拒绝时外部改成 remote 并推进版本;后续写入必须用该版本,成功回执保存后再模拟 502 丢响应。
const transport=async request=>{
calls.push(request);if(request.method==='GET')return{status:200,body:request.path.endsWith('/model')?{model:model(true)}:receipts.get(request.path.split('/').at(-1))};
const body=JSON.parse(request.body);if(reject){reject=false;server=applyChanges(server,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);version++;return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};}
assert(body.expected_versions[root]===version,'replanned write did not use reviewed version');for(const operation of body.operations)server=applyChanges(server,operation.changes);version++;
const receipt={protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)};receipts.set(request.requestId,receipt);return lose?{status:502,body:{error:'lost_response'}}:{status:200,body:receipt};
};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);const edit=engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();
// 记录原拒绝并以 keep-local 生成新恢复计划,审计、历史和替代身份一起发布。
const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();
// 替代服务已写入但客户端仍 unknown,不能猜成功;先关闭,修复网络后重开查询。
const replacement=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.id!==old.id);assert(replacement&&replacement.outcome==='unresolved','lost replacement ACK was guessed successful');await engine.close();lose=false;
// 把恢复时钟前移以触发原请求查询,有限轮询等请求 resolved,再等待引擎收敛。
engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}});
for(let i=0;i<100&&engine.getSnapshot().runtime.ledger.requests.get(replacement.id)?.phase!=='resolved';i++)await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
// 恢复审计、旧 not-applied 和两份 body 都保留;总 POST 只应两次,undo 才另发回到 remote 的修改。
const restored=engine.getSnapshot();assert(restored.runtime.recoveries.has(plan.id),'recovery audit disappeared');assert(restored.runtime.ledger.requests.get(old.id).body===old.body&&restored.runtime.ledger.requests.get(old.id).outcome==='not-applied','old rejected request was rewritten');
assert(restored.runtime.ledger.requests.get(replacement.id).body===replacement.body,'replacement identity changed after reopening');assert(calls.filter(call=>call.method==='POST').length===2,'recovery repeated a server write');
assert(engine.editor.history.undo().status==='applied','replanned history did not restore');await engine.settle();assert(engine.getSnapshot().document.elements.get('a').data.title==='remote','undo did not restore the reviewed server value');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-conflict-replan`);}
}
async function canonicalRefreshCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
const target={id:'a',incarnation:1},element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}};
const original=createDocument(root,[{id:root,parent:null}],[element]),storage=createBrowserStorage({name:`${name}-canonical-refresh`}),calls=[];let server=original,version=1,engine;
const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:version}});
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);for(const operation of body.operations)server=applyChanges(server,operation.changes);version++;return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();
const historyId=engine.getSnapshot().history.undo[0],request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];
server=applyChanges(server,[{kind:'field',target,field:'geometry',before:element.geometry,after:{...element.geometry,x:400}}]);version++;
const session=await engine.prepareRecovery();assert(session.conflict===undefined,'clean canonical refresh reported a conflict');
assert(engine.getSnapshot().document.elements.get('a').geometry.x===0,'preview mutated the working document');
assert(engine.editor.history.undo().code==='recovery_in_progress','history bypassed the recovery fence');
assert((await session.apply()).status==='applied','fresh canonical state did not publish');await engine.close();
engine=await openProjectEngine(options);const restored=engine.getSnapshot();
assert(restored.document.elements.get('a').geometry.x===400&&restored.document.elements.get('a').data.title==='local','canonical refresh did not survive IndexedDB recovery');
assert(restored.runtime.versions.get(root)===3&&restored.history.undo[0]===historyId,'refresh lost its version/history cut');
assert(restored.runtime.ledger.requests.size===1&&restored.runtime.ledger.requests.get(request.id).body===request.body,'refresh changed an original request');
assert(calls.filter(call=>call.method==='POST').length===1,'refresh or reopen resent an applied write');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-canonical-refresh`);}
}
async function runRecoveryCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),runId=crypto.randomUUID();
const registry=createRegistry(builtinComponents()),element={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}},original=createDocument(root,[{id:root,parent:null}],[element]);
const storage=createBrowserStorage({name:`${name}-runs`}),calls=[];let accepted,after,plan,engine;
const chunk=(document,version)=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:{},versions:{[root]:version}});
const transport=async request=>{
calls.push(request);
if(request.method==='POST'){
const input=JSON.parse(request.body),configured=applyChanges(original,[{kind:'field',target:{id:'a',incarnation:1},field:'data.ai',before:null,beforePresent:false,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:{},propagate:false},afterPresent:true}]);
plan={steps:[{step_id:'step-a',board_id:root,node_id:'a',target_incarnation:1,canonical_root:root}],edges:[],frozen_versions:{[root]:2}};
accepted={protocol_version:2,request_id:request.requestId,operation_id:input.operation_id,admission:'accepted',status:'queued',run_id:runId,plan,canvas_version:2,document_format_version:1,target_incarnation:1,model:chunk(configured,2)};
after=applyChanges(configured,[{kind:'field',target:{id:'a',incarnation:1},field:'data.text',before:'original',after:'durable generated',beforePresent:true,afterPresent:true}]);
return{status:502,body:{error:'response_lost'}};
}
if(request.path.includes('/ai/operations/'))return{status:200,body:accepted};
return{status:200,body:{run_id:runId,status:'succeeded',plan,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'},{seq:2,run_id:runId,type:'step.applied',step_id:'step-a',board_id:root,node_id:'a',target_incarnation:1,document_format_version:1,canvas_version:3,model:chunk(after,3)}],next_seq:2,has_more:false,outputs:[],steps:[]}};
};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport}),runs:createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);const detach=engine.sessions.attach('view');engine.startRun({target:{id:'a',incarnation:1},boardId:root,modelId:'text',prompt:'Generate',parameters:{},propagate:false});detach();await engine.settle();
const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(engine.getSnapshot().runtime.runs.size===0,'lost admission was guessed as resolved');await engine.close();
engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}});
for(let i=0;i<100&&engine.getSnapshot().runtime.runs.get(runId)?.cursor!==2;i++)await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
assert(calls.filter(call=>call.method==='POST').length===1,'recovery invoked a second generation');assert(calls.some(call=>call.path.endsWith('/ai/operations/'+request.id)),'recovery lost the original admission identity');
assert(engine.getSnapshot().runtime.runs.get(runId)?.cursor===2&&engine.getSnapshot().document.elements.get('a').data.text==='durable generated','run and canonical result did not publish together');assert(engine.getSnapshot().history.undo.length===0,'ACK replay created user history');await engine.close();
const leader=await storage.claim(options.scope);try{const image=await storage.recover(leader);assert(image.payload.runtime.runs[0][1].cursor===2,'run cursor was not durable');assert(image.payload.document.elements[0].data.text==='durable generated','cursor advanced without durable document');}finally{await leader.release();}
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-runs`);}
}
async function atomicResourceDeletionCase(name,assert){
const database=`${name}-file-collection`,storage=createBrowserStorage({name:database,checkpointEvery:2}),scope={accountId:'account',workspaceId:'workspace',projectId:'project'};let leader,other;
const marker={version:1,unreferencedAt:100,purgedAt:200},image={formatVersion:3,rootRevision:1,localRevision:0,payload:{runtime:{resources:[['asset',{retirement:marker}]]}}};
try{
other=await storage.claim({...scope,projectId:'other-project'});await storage.commit(other,{image:{formatVersion:1,rootRevision:0,localRevision:0,payload:{}},files:[{id:'asset',bytes:new Blob(['other project'])}]});
leader=await storage.claim(scope);await storage.commit(leader,{image:{formatVersion:1,rootRevision:0,localRevision:0,payload:{}},files:[{id:'asset',bytes:new Blob(['delete me'])},{id:'keep',bytes:new Blob(['keep me'])}]});
const nativeDelete=IDBObjectStore.prototype.delete;let queued=false;
IDBObjectStore.prototype.delete=function(key){const request=nativeDelete.call(this,key);if(this.name==='files'&&Array.isArray(key)&&key[1]==='asset'){queued=true;throw Error('fault_after_delete');}return request;};
let failure;try{await storage.commit(leader,{image,deleteFiles:['asset']});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.delete=nativeDelete;}
assert(queued&&failure==='fault_after_delete','fault did not occur after a real IDB delete request');assert(await(await storage.readFile(leader,'asset')).text()==='delete me','aborted GC transaction lost bytes');assert((await storage.recover(leader)).rootRevision===0,'aborted GC transaction advanced recovery');
const ids=['asset'],pending=storage.commit(leader,{image,deleteFiles:ids});ids[0]='keep';await pending;
assert(await storage.readFile(leader,'asset')===undefined&&await(await storage.readFile(leader,'keep')).text()==='keep me','mutable deletion descriptor escaped capture');assert((await storage.recover(leader)).formatVersion===3,'GC cut did not persist');await storage.commit(leader,{image,deleteFiles:['asset']});
let code;try{await storage.commit(leader,{image:{...image,rootRevision:2},deleteFiles:['keep']});}catch(error){code=error.code;}assert(code==='invalid_file_deletion','file deletion without a matching purge marker was accepted');
await leader.release();leader=await storage.claim(scope);assert(await storage.readFile(leader,'asset')===undefined&&(await storage.recover(leader)).rootRevision===1,'GC did not survive owner reopening');assert(await(await storage.readFile(other,'asset')).text()==='other project','local GC crossed its project scope');
}finally{await other?.release();await leader?.release();storage.close();indexedDB.deleteDatabase(database);}
}
async function importSourceCollectionCase(name,assert){
const database=`${name}-import-source-gc`,storage=createBrowserStorage({name:database,checkpointEvery:3}),scope={accountId:'account',workspaceId:'workspace',projectId:'project'},registry=createRegistry(builtinComponents()),original=createDocument('project',[{id:'P',parent:null}],[]),receipts=new Map();let engine,owner,server=original,version=1,now=1000;
const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{P:version}}),async submit(request){const {intent}=JSON.parse(request.body);server=applyChanges(server,intent.changes);receipts.set(request.id,{document:server,versions:new Map([['P',++version]])});return{outcome:'applied'};},async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},canonical:request=>receipts.get(request.id)};
const options={scope,registry,document:original,versions:new Map([['P',1]]),storage:{...storage,commit:(leader,batch)=>{owner=leader;return storage.commit(leader,batch);}},persistence:port,clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}};
try{
engine=await openProjectEngine(options);const line={kind:'element',key:'text',record:{id:'text',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'$target',data:{title:'text',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}},id=engine.imports.startNdjson(new Blob([JSON.stringify(line)+'\nnot-json']),'P');await engine.settle();const job=engine.imports.getJobs().get(id),fileId=job.source.fileId;assert(job.status==='partial','partial source fixture did not finish');const document=engine.getSnapshot().document,history=engine.getSnapshot().history;
await engine.collectImportSources({retentionMs:100});now+=100;const nativeDelete=IDBObjectStore.prototype.delete;let injected=false;
IDBObjectStore.prototype.delete=function(key){const request=nativeDelete.call(this,key);if(!injected&&this.name==='files'&&Array.isArray(key)&&key[1]===fileId){injected=true;throw Error('source_gc_abort');}return request;};
let failure;try{await engine.collectImportSources({retentionMs:100});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.delete=nativeDelete;}
assert(injected&&failure==='source_gc_abort','source GC did not reach an IDB deletion fault');assert((await storage.readFile(owner,fileId)).size===job.source.byteLength,'source bytes vanished after aborted GC');const soft=await storage.recover(owner);assert(soft.formatVersion===17&&soft.payload.runtime.imports[0][1].source.retirement.purgedAt===undefined,'aborted source GC advanced the purge cut');
await engine.persist();assert(await storage.readFile(owner,fileId)===undefined,'source deletion retry did not remove bytes');const cut=await storage.recover(owner);assert(cut.formatVersion===17&&decodeRecovery(cut,registry).runtime.imports.get(id).source.retirement.purgedAt===1100,'source purge and recovery marker disagree');assert(engine.getSnapshot().document===document&&engine.getSnapshot().history===history,'source GC changed content/history');
let oldReader;try{decodeRecovery({...cut,formatVersion:3},registry);}catch(error){oldReader=error.code;}assert(oldReader==='unsupported_recovery_version','source retirement was accepted as v3');await engine.close();engine=await openProjectEngine(options);await engine.imports.resume(id);await engine.settle();assert(engine.imports.getJobs().get(id).status==='partial'&&engine.imports.getJobs().get(id).failures.length===1,'reopening a retired source lost its failure report');let retired;try{await engine.imports.openSource(id);}catch(error){retired=error.code;}assert(retired==='import_source_retired','retired source was silently reinterpreted or fetched as an asset');assert(engine.editor.history.undo().status==='applied','undo depended on purged source');await engine.settle();assert(engine.getSnapshot().document.elements.size===0,'undo retained imported data');assert(engine.editor.history.redo().status==='applied','redo depended on purged source');await engine.settle();assert(engine.getSnapshot().document.elements.size===1,'redo did not restore imported content');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
async function orphanInventoryCase(name,assert){
const database=`${name}-orphan-inventory`,storage=createBrowserStorage({name:database,checkpointEvery:3}),scope={accountId:'account',workspaceId:'workspace',projectId:'project'},registry=createRegistry(builtinComponents()),document=createDocument('project',[{id:'P',parent:null}],[{id:'image',incarnation:1,ownerCanvasId:'P',structure:'node',componentId:'image',dataVersion:1,data:{title:'image',text:'',assetId:'z-pinned'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}]);let engine,owner,other,now=1000;
const options={scope,registry,document,versions:new Map([['P',1]]),storage:{...storage,claim:async scope=>{owner=await storage.claim(scope);return owner;}},persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected write');},canonical(){throw Error('unexpected write');}},clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}};
try{
engine=await openProjectEngine(options);await engine.persist();await engine.close();let legacy=await storage.claim(scope),cut=await storage.recover(legacy);await storage.commit(legacy,{image:{...cut,rootRevision:cut.rootRevision+1},files:[{id:'a-orphan',bytes:new Blob(['orphan bytes'])},{id:'z-pinned',bytes:new Blob(['pinned bytes'])}]});await legacy.release();
other=await storage.claim({...scope,projectId:'other'});await storage.commit(other,{image:{formatVersion:1,rootRevision:0,localRevision:0,payload:{}},files:[{id:'a-foreign',bytes:new Blob(['other project'])}]});engine=await openProjectEngine(options);
const before=engine.getSnapshot(),first=await engine.inventoryLocalFiles({limit:1});assert(first.files.length===1&&first.files[0].id==='a-orphan'&&first.nextAfter==='a-orphan','inventory paging/scope prefix is wrong');assert(engine.getSnapshot()===before,'read-only inventory mutated the Root');const last=await engine.inventoryLocalFiles({after:first.nextAfter,limit:1});assert(last.files[0].id==='z-pinned'&&last.files[0].pins.length>0&&last.nextAfter===undefined,'referenced unregistered bytes lost their pin');
await engine.collectOrphanFiles({retentionMs:100});now+=100;const nativeDelete=IDBObjectStore.prototype.delete;let injected=false;
IDBObjectStore.prototype.delete=function(key){const request=nativeDelete.call(this,key);if(!injected&&this.name==='files'&&Array.isArray(key)&&key[1]==='a-orphan'){injected=true;throw Error('orphan_gc_abort');}return request;};
let failure;try{await engine.collectOrphanFiles({retentionMs:100});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.delete=nativeDelete;}
assert(injected&&failure==='orphan_gc_abort','orphan GC did not reach post-delete rollback');assert(await(await storage.readFile(owner,'a-orphan')).text()==='orphan bytes','orphan byte rollback failed');cut=await storage.recover(owner);assert(cut.formatVersion===5&&cut.payload.runtime.orphanFiles[0][1].retirement.purgedAt===undefined,'aborted orphan GC advanced its cut');await engine.persist();assert(await storage.readFile(owner,'a-orphan')===undefined,'orphan deletion retry failed');assert(await(await storage.readFile(owner,'z-pinned')).text()==='pinned bytes','orphan GC deleted referenced bytes');assert(await(await storage.readFile(other,'a-foreign')).text()==='other project','orphan GC crossed its project scope');
cut=await storage.recover(owner);assert(decodeRecovery(cut,registry).runtime.orphanFiles.get('a-orphan').retirement.purgedAt===1100,'orphan v5 record failed recovery');let stale;try{await storage.scanFiles(owner,{expectedRootRevision:before.rootRevision,limit:1});}catch(error){stale=error.code;}assert(stale==='stale_file_inventory','old inventory revision was accepted');
await engine.close();legacy=await storage.claim(scope);let reuse;try{await storage.commit(legacy,{image:{...cut,rootRevision:cut.rootRevision+1},files:[{id:'a-orphan',bytes:new Blob(['reused identity'])}]});}catch(error){reuse=error.code;}assert(reuse==='resource_identity_conflict','retired file identity was reused');await legacy.release();engine=await openProjectEngine(options);let retired;try{await engine.displayResource('a-orphan');}catch(error){retired=error.code;}assert(retired==='local_file_retired','retired orphan became a guessed remote asset');assert(engine.getSnapshot().document.elements.has('image'),'orphan GC mutated the document');
}finally{await engine?.close();await other?.release();storage.close();indexedDB.deleteDatabase(database);}
}
async function resourceRecoveryCase(name, assert) {
const scope = { accountId: crypto.randomUUID(), workspaceId: crypto.randomUUID(), projectId: crypto.randomUUID() };
const registry = createRegistry(builtinComponents()), document = createDocument(scope.projectId, [{ id: scope.projectId, parent: null }], []);
const storage = createBrowserStorage({ name: `${name}-resources` }), receipts = new Map(), uploads = [], queries = [], writes = [];
const assets = createFoundryAssets({ workspaceId: scope.workspaceId, transport: async input => {
if (input.method === 'POST') {
uploads.push(input); const file = input.body.get('image');
assert(await file.text() === 'durable-image-bytes', 'upload reconstructed different file bytes');
receipts.set(input.requestId, { protocol_version: 2, request_id: input.requestId, status: 'applied', asset: { id: input.resourceId, bytes: file.size, mime_type: 'image/png', width: 2, height: 1, url: assets.sourceURL(input.resourceId) } });
return { status: 502, body: { error: 'lost_upload_ack' } };
}
queries.push(input); return { status: 200, body: receipts.get(input.path.split('/').at(-1)) };
} });
let server = document;
const persistence = {
compile: (intent, id, snapshot) => ({ payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: { [scope.projectId]: snapshot.runtime.versions.get(scope.projectId) } }),
async submit(request) { writes.push(request); server = applyChanges(server, JSON.parse(request.body).intent.changes); return { outcome: 'applied' }; },
async query() { return { outcome: 'unresolved' }; },
canonical: () => ({ document: server, versions: new Map([[scope.projectId, writes.length + 1]]) }),
};
const options = { scope, registry, document, versions: new Map([[scope.projectId, 1]]), storage, persistence, assets,historyLimit:1 };
let engine, resource;
try {
engine = await openProjectEngine(options);
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: { id: 'picture', incarnation: 1, componentId: 'image', dataVersion: 1, structure: 'node', ownerCanvasId: scope.projectId, data: { title: 'Picture', text: '', src: ref.src, assetId: ref.id }, geometry: { x: 10, y: 20, width: 100, height: 80, rotation: 0 } } });
});
assert(result.status === 'applied' && engine.getSnapshot().document.elements.has('picture'), 'pending image was hidden until prepare/upload');
const handle = await engine.displayResource(resource.id); assert(handle.url.startsWith('blob:'), 'pending image did not expose a local preview'); handle.release();
await engine.settle(); assert(uploads.length === 1 && writes.length === 0, 'document was sent before its resource was confirmed');
await engine.close();
const leader = await storage.claim(scope), image = await storage.recover(leader);
assert(await (await storage.readFile(leader, resource.id)).text() === 'durable-image-bytes', 'file bytes did not survive actual IndexedDB recovery');
assert(image.payload.runtime.resources[0][1].id === resource.id && image.payload.document.elements[0].data.assetId === resource.id, 'resource metadata and element belong to different durable cuts');
await leader.release();
engine = await openProjectEngine({ ...options, clock: { now: () => Date.now() + 100000, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: id => clearTimeout(id) } });
await new Promise(resolve => setTimeout(resolve, 10)); await engine.settle();
assert(uploads.length === 1 && queries.length === 1 && writes.length === 1, 'reopen uploaded again or lost the pending element operation');
assert(queries[0].path.endsWith(uploads[0].requestId), 'recovery queried a replacement request identity');
assert(engine.getSnapshot().runtime.resources.get(resource.id).stored.id === resource.id, 'confirmed asset changed resource identity');
assert(engine.getSnapshot().history.undo.length === 1 && engine.getSnapshot().document.elements.size === 1, 'resource ACK recreated the node/history');
assert((await engine.collectResources({retentionMs:0})).purgedIds.length===0,'live image was collected');
engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});await engine.settle();assert((await engine.collectResources({retentionMs:0})).purgedIds.length===0,'undo-owned image was collected');
engine.editor.dispatch({type:'element.create',element:{id:'keep-text',incarnation:1,componentId:'text',dataVersion:1,structure:'node',ownerCanvasId:scope.projectId,data:{title:'keep',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}});await engine.settle();
const collected=await engine.collectResources({retentionMs:0});assert(collected.purgedIds.length===1&&collected.purgedIds[0]===resource.id,'unreferenced local image was not collected');await engine.close();
const inspected=await storage.claim(scope),cut=await storage.recover(inspected);assert(cut.formatVersion===3&&await storage.readFile(inspected,resource.id)===undefined,'recovered GC marker and byte deletion disagree');assert(decodeRecovery(cut,registry).runtime.resources.get(resource.id).retirement.purgedAt!==undefined,'GC marker did not decode');await inspected.release();
engine=await openProjectEngine(options);const remote=await engine.displayResource(resource.id);assert(remote.url===assets.displayURL(resource.id),'purged confirmed image did not fall back to its existing remote asset');remote.release();assert(uploads.length===1,'reopening collected bytes repeated upload');
} finally { await engine?.close(); storage.close(); indexedDB.deleteDatabase(`${name}-resources`); }
}
async function sourceIntegrityCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),registry=createRegistry(builtinComponents()),databaseName=`${name}-source-integrity`,storage=createBrowserStorage({name:databaseName}),scope={accountId:crypto.randomUUID(),workspaceId:workspace,projectId:root},original=createDocument(root,[{id:root,parent:null}],[]);let document=original,versions=new Map([[root,1]]),engine,calls=0,raw;
const transport=async request=>{calls++;const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'integrity lost a version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(row=>row.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(versions)}}};};
const node=id=>({kind:'element',key:id,record:{id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'$target',data:{title:id,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}}}),bytes=new Blob([[node('first'),node('last')].map(row=>JSON.stringify(row)).join('\n')]),options={scope,registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);const id=engine.imports.startNdjson(bytes,root);await new Promise(resolve=>{const stop=engine.editor.subscribe((_,snapshot)=>{if(snapshot.runtime.imports.get(id).committedSourceKeys.length===1){stop();resolve();}});});await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id),first=before.stableIds['element:first'];assert(/^[a-f0-9]{64}$/.test(before.source.sha256),'source hash was not bound before publication');await engine.close();const sent=calls;
raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(databaseName);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const key=[JSON.stringify([scope.accountId,scope.workspaceId,scope.projectId]),before.source.fileId];
const write=async value=>{const tx=raw.transaction('files','readwrite'),done=new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);});tx.objectStore('files').put(value,key);await done;};const text=await bytes.text(),changed=text.replaceAll('last','evil');assert(new Blob([changed]).size===bytes.size,'corruption fixture changed its byte length');await write(new Blob([changed]));engine=await openProjectEngine(options);await engine.imports.resume(id);await engine.settle();assert(engine.imports.getJobs().get(id).error==='import_source_changed','same-length source tampering was accepted');assert(engine.getSnapshot().document.elements.size===1&&engine.getSnapshot().document.elements.has(first)&&calls===sent,'source tampering changed the preserved prefix or sent new work');
await write(bytes);await engine.imports.resume(id);await engine.settle();assert(engine.imports.getJobs().get(id).status==='completed'&&document.elements.size===2&&engine.imports.getJobs().get(id).stableIds['element:first']===first,'restoring exact bytes did not resume the original task');
}finally{raw?.close();await engine?.close();storage.close();indexedDB.deleteDatabase(databaseName);}
}
async function deltaJournalCase(name,assert){
const databaseName=`${name}-delta-journal`,scope={accountId:'delta-account',workspaceId:'workspace',projectId:'project'},key=JSON.stringify(Object.values(scope)),rows=Array.from({length:100},(_,i)=>({id:String(i),text:'x'.repeat(1000)})),image=revision=>({formatVersion:1,rootRevision:revision,localRevision:revision,payload:{rows:rows.map((row,i)=>i===0?{...row,text:`version-${revision}`}:row),ledger:{frozenBody:'exact original request bytes'}}});
const done=tx=>new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);}),read=request=>new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
let raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(databaseName,1);request.onupgradeneeded=()=>{for(const store of ['metadata','checkpoints','journal','files'])request.result.createObjectStore(store);};request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
const initial=image(0),checksum=[...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(initial))))].map(byte=>byte.toString(16).padStart(2,'0')).join('');let tx=raw.transaction(['metadata','checkpoints'],'readwrite'),terminal=done(tx);tx.objectStore('metadata').put({fence:0,throughRevision:0,checkpointRevision:0},key);tx.objectStore('checkpoints').put({image:initial,checksum},[key,0]);await terminal;raw.close();
let storage=createBrowserStorage({name:databaseName,checkpointEvery:3}),leader;
try{
leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(initial),'legacy checkpoint did not migrate');
const digest=crypto.subtle.digest,hashed=[];crypto.subtle.digest=function(algorithm,bytes){hashed.push(bytes.byteLength);return digest.call(this,algorithm,bytes);};try{const first=image(1);await storage.commit(leader,{image:sealRecoveryImage(first.formatVersion,first.rootRevision,first.localRevision,first.payload),files:[{id:'asset',bytes:new Blob(['immutable'])}]});}finally{crypto.subtle.digest=digest;}
assert(hashed.length===1&&hashed[0]<JSON.stringify(image(1)).length/10,'a delta commit still hashed the whole image');
raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(databaseName);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
const row=await read(raw.transaction('journal').objectStore('journal').get([key,1]));assert(row.journalVersion===2&&!row.image&&JSON.stringify(row).length<JSON.stringify(image(1)).length/10,'small edit repeated the full image');
let code;try{await storage.commit(leader,{image:image(2),files:[{id:'orphan',bytes:new Blob(['orphan'])},{id:'asset',bytes:new Blob(['replacement'])}]});}catch(error){code=error.code;}assert(code==='resource_identity_conflict'&&await storage.readFile(leader,'orphan')===undefined,'delta transaction leaked a file or advanced its cut');assert((await storage.recover(leader)).rootRevision===1,'aborted delta advanced the head');await storage.commit(leader,{image:image(2)});await leader.release();storage.close();storage=createBrowserStorage({name:databaseName,checkpointEvery:3});leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(image(2)),'fresh adapter did not replay the delta chain');
const intact=await read(raw.transaction('journal').objectStore('journal').get([key,2]));
const hash=async value=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(value))))].map(byte=>byte.toString(16).padStart(2,'0')).join(''),legacy={...row,journalVersion:1,checksum:await hash(image(1))},mixed={...intact,baseChecksum:await hash(image(1))};mixed.checksum=await hash({journalVersion:mixed.journalVersion,baseRevision:mixed.baseRevision,baseChecksum:mixed.baseChecksum,header:mixed.header,patches:mixed.patches});
let mixedTx=raw.transaction('journal','readwrite'),mixedDone=done(mixedTx);mixedTx.objectStore('journal').put(legacy,[key,1]);mixedTx.objectStore('journal').put(mixed,[key,2]);await mixedDone;assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(image(2)),'mixed legacy-v1 and chained-v2 deltas did not recover');mixedTx=raw.transaction('journal','readwrite');mixedDone=done(mixedTx);mixedTx.objectStore('journal').put(row,[key,1]);mixedTx.objectStore('journal').put(intact,[key,2]);await mixedDone;await storage.recover(leader);
const write=async value=>{const tx=raw.transaction('journal','readwrite'),wait=done(tx);tx.objectStore('journal').put(value,[key,2]);await wait;};
for(const[kind,expected]of [['payload','recovery_checksum_mismatch'],['header','recovery_checksum_mismatch'],['base','recovery_delta_base_mismatch'],['future','recovery_delta_interpreter_unavailable']]){const damaged=structuredClone(intact);if(kind==='payload')damaged.patches[0].value='tampered';else if(kind==='header')damaged.header.formatVersion=2;else if(kind==='base')damaged.baseChecksum='0'.repeat(64);else damaged.journalVersion=99;await write(damaged);code=undefined;try{await storage.recover(leader);}catch(error){code=error.code;}assert(code===expected,`delta ${kind} corruption was not detected: ${code}`);await write(intact);}
const checkpointHashes=[];crypto.subtle.digest=function(algorithm,bytes){checkpointHashes.push(bytes.byteLength);return digest.call(this,algorithm,bytes);};try{await storage.commit(leader,{image:image(3)});}finally{crypto.subtle.digest=digest;}assert(checkpointHashes.length===1&&checkpointHashes[0]===new TextEncoder().encode(JSON.stringify(image(3))).byteLength,'checkpoint did not hash the full image');assert((await storage.recover(leader)).rootRevision===3,'checkpoint after deltas failed');assert(await read(raw.transaction('journal').objectStore('journal').count())===0,'checkpoint did not compact prior deltas');
const detached=await storage.recover(leader);detached.payload.rows[0].text='caller mutation';const fourth=image(4),fifth=image(5),one=storage.commit(leader,{image:fourth}),two=storage.commit(leader,{image:fifth});fifth.payload.rows[0].text='late mutation';await Promise.all([one,two]);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(image(5)),'queued commits or recovered callers mutated the cached base');await storage.commit(leader,{image:image(5)});
const reordered=image(5);reordered.payload={ledger:reordered.payload.ledger,rows:reordered.payload.rows};code=undefined;try{await storage.commit(leader,{image:reordered});}catch(error){code=error.code;}assert(code==='storage_revision_conflict','same-cut retry accepted different serialized property order');
await storage.commit(leader,{image:image(6)});const extended={marker:'retained header',...image(7)};await storage.commit(leader,{image:extended});assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(extended),'noncanonical header lost fields or property order');const fullRow=await read(raw.transaction('journal').objectStore('journal').get([key,7]));assert(fullRow.checkpointVersion===1&&fullRow.bytes instanceof Blob,'noncanonical header was encoded as a lossy delta');
const metadata=await read(raw.transaction('metadata').objectStore('metadata').get(key));let metadataTx=raw.transaction('metadata','readwrite'),metadataDone=done(metadataTx);const missingHead={...metadata};delete missingHead.throughRevision;metadataTx.objectStore('metadata').put(missingHead,key);await metadataDone;code=undefined;try{await storage.commit(leader,{image:image(8)});}catch(error){code=error.code;}assert(code==='recovery_delta_base_mismatch','cached baseline overwrote missing durable head');metadataTx=raw.transaction('metadata','readwrite');metadataDone=done(metadataTx);metadataTx.objectStore('metadata').put(metadata,key);await metadataDone;assert((await storage.recover(leader)).rootRevision===7,'head validation failure advanced cached state');
const oldError=await new Promise(resolve=>{const request=indexedDB.open(databaseName,1);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(oldError==='VersionError','old database reader accepted the delta format');
}finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(databaseName);}
}
async function importBackpressureCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-import-pressure`,storage=createBrowserStorage({name:database}),original=createDocument(root,[{id:root,parent:null}],[]);let document=original,versions=new Map([[root,1]]),engine,calls=0,release;const gate=new Promise(resolve=>{release=resolve;});
const transport=async request=>{calls++;const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'pressure lost a version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);const participants=Object.keys(body.expected_versions);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(row=>row.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}}};};
const options={scope:{accountId:crypto.randomUUID(),workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage:{...storage,async commit(owner,batch){if(batch.files?.length)await gate;return storage.commit(owner,batch);}},persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);await engine.persist();const id=engine.imports.startNdjson(new Blob([Array.from({length:500},(_,index)=>JSON.stringify({kind:'element',key:`n-${index}`,record:{id:`n-${index}`,incarnation:1,componentId:'text',dataVersion:1,structure:'node',ownerCanvasId:'$target',data:{title:`n-${index}`,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}}})).join('\n')]),root);
await new Promise((resolve,reject)=>{const stop=engine.editor.subscribe((_,snapshot)=>{if(snapshot.rootRevision-engine.getDurability().durableRootRevision>=32){clearTimeout(timer);stop();resolve();}}),timer=setTimeout(()=>{stop();reject(Error('browser import did not reach its durability budget'));},5000);});
const count=engine.imports.getJobs().get(id).committedSourceKeys.length;assert(count>1&&count<500&&calls===0,'source durability did not bound speculative progress or fence the network');await new Promise(resolve=>setTimeout(resolve,20));assert(engine.imports.getJobs().get(id).committedSourceKeys.length===count,'backpressure did not stop the producer');release();await engine.settle();assert(document.elements.size===500&&engine.imports.getJobs().get(id).status==='completed','pressure release did not complete all records');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.size===500&&engine.imports.getJobs().get(id).committedSourceKeys.length===500,'IndexedDB lost the completed prefix');
}finally{release();await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
async function referenceCycleCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-reference-cycle`,storage=createBrowserStorage({name:database}),original=createDocument(root,[{id:root,parent:null}],[]);
let document=original,versions=new Map([[root,1]]),engine;
const transport=async request=>{const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'cycle lost a version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);const participants=Object.keys(body.expected_versions);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(row=>row.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(row=>participants.includes(row.id)),elements:[...document.elements.values()].filter(row=>participants.includes(row.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}}};};
const options={scope:{accountId:crypto.randomUUID(),workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
const node=(id,owner,componentId='text')=>({kind:'element',key:id,record:{id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:'',...(componentId==='frame'?{ports:[{id:'in',direction:'input',nodeId:'A',handle:'left'}]}:{})},geometry:{x:0,y:0,width:200,height:100,rotation:0}}});
try{
engine=await openProjectEngine(options);const imported=engine.imports.startNdjson(new Blob([[node('F','$target','frame'),{kind:'canvas',key:'S',record:{id:'S',parent:{canvasId:'$target',entryElementId:'F'}}},node('A','S')].map(row=>JSON.stringify(row)).join('\n')]),root);await engine.settle();const first=engine.imports.getJobs().get(imported),F=first.stableIds['element:F'],A=first.stableIds['element:A'];assert(first.status==='completed'&&first.committedSourceKeys.length===3,'native cycle did not complete atomically');assert(document.elements.get(F).data.ports[0].nodeId===A,'native port kept the source ID');
const copied=await engine.imports.duplicate([{id:F,incarnation:1}]);await engine.settle();const job=engine.imports.getJobs().get(copied),F2=job.stableIds[`element:${F}`],A2=job.stableIds[`element:${A}`];assert(job.status==='completed'&&job.committedSourceKeys.length===3,'copy cycle lost a member');assert(document.elements.get(F2).data.ports[0].nodeId===A2&&A2!==A,'copy port attached to the original child');
await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get(F2).data.ports[0].nodeId===A2,'IndexedDB lost the copied reference');assert(engine.editor.history.undo().status==='applied','cycle undo failed');await engine.settle();assert(!document.elements.has(F2)&&!document.elements.has(A2)&&document.elements.has(F),'cycle undo exposed half a group');assert(engine.editor.history.redo().status==='applied','cycle redo failed');await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get(F2).data.ports[0].nodeId===A2&&engine.getSnapshot().document.elements.get(A2).incarnation===2,'cycle redo/reopen lost the restored identity');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
async function duplicateRecoveryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-duplicate`,storage=createBrowserStorage({name:database});
const node=(id,owner=root,componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'',...(componentId==='image'?{assetId:'shared',src:'asset:shared',runId:'do-not-run'}:{})},geometry:{x:0,y:0,width:200,height:100,rotation:0}});
const edge={id:'incoming',incarnation:1,structure:'connection',ownerCanvasId:root,componentId:'connection',dataVersion:1,source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}},original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[node('C'),node('F',root,'frame'),node('A',child,'image'),edge]);
let document=original,versions=new Map([[root,1],[child,1]]),engine;
const model=participants=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
const transport=async request=>{const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'copy lost expected version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(Object.keys(body.expected_versions))}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
let queryMismatch=false,queryPublications=0;
const open=async()=>{const current=await openProjectEngine(options);current.editor.subscribe((_,snapshot)=>{queryPublications++;try{const queries=current.editor.getQueries(snapshot.document);if(queries.document!==snapshot.document||queries.revision!==snapshot.revision)queryMismatch=true;}catch{queryMismatch=true;}});return current;};
try{
engine=await open();const id=await engine.imports.duplicate([{id:'F',incarnation:1},{id:'A',incarnation:1}]);
await engine.imports.pause(id);await engine.settle();assert(engine.imports.getJobs().get(id).source.kind==='duplicate-snapshot','copy did not expose its preparing source');await engine.close();engine=await open();assert(engine.imports.getJobs().get(id).status==='paused'&&engine.imports.getJobs().get(id).preparation.selection.length===2,'preparing copy source/selection did not survive IndexedDB');await engine.imports.resume(id);
await new Promise(resolve=>{if(engine.imports.getJobs().get(id).committedSourceKeys.length){resolve();return;}const release=engine.editor.subscribe((_,snapshot)=>{if(snapshot.runtime.imports.get(id).committedSourceKeys.length===1){release();resolve();}});});await engine.imports.pause(id);await engine.settle();
const before=engine.imports.getJobs().get(id),copied=before.stableIds['element:F'];assert(before.status==='paused','copy pause lost');engine.editor.dispatch({type:'element.data',target:{id:copied,incarnation:1},patch:{title:'edited copy'}});await engine.settle();await engine.close();engine=await open();
const restored=engine.imports.getJobs().get(id);assert(restored.source.kind==='duplicate-plan'&&JSON.stringify(restored.stableIds)===JSON.stringify(before.stableIds),'copy source or mapping changed on reopen');await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id),image=document.elements.get(job.stableIds['element:A']),incoming=document.elements.get(job.stableIds['element:incoming']);
assert(job.status==='completed'&&job.failures.length===0,'copy did not complete after reopening');assert(document.elements.get(copied).data.title==='edited copy','copy replay overwrote an edit');assert(image.data.assetId==='shared'&&!Object.hasOwn(image.data,'runId'),'copy reissued image execution');assert(incoming.source.elementId==='C'&&incoming.target.elementId===copied&&incoming.ownerCanvasId===root,'copy lost upper incoming source');assert(document.elements.has('incoming')&&document.canvases.has(job.stableIds[`canvas:${child}`]),'copy changed originals or lost its child canvas');const queries=engine.editor.getQueries();assert(queries.members(job.stableIds[`canvas:${child}`]).includes(job.stableIds['element:A']),'recovered query lost copied member');assert(queries.relations.incidentEdges.get('C').has(job.stableIds['element:incoming']),'query lost upper incoming edge');
assert(engine.editor.history.undo().status==='applied','copy undo failed');await engine.settle();const count=document.elements.size;await engine.close();engine=await open();await engine.settle();assert(document.elements.size===count&&!document.elements.has(job.stableIds['element:A']),'terminal copy revived undone records');assert(!engine.editor.getQueries().relations.childCanvasByEntry.has(copied),'undo retained a stale child binding');assert(!queryMismatch&&queryPublications>0,'publication exposed a document/index revision mismatch');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
async function duplicatePlannerRecoveryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-duplicate-planner`,storage=createBrowserStorage({name:database});
const node=(id,owner,componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}});
const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[node('F',root,'frame'),...Array.from({length:500},(_,i)=>node(`member-${i}`,child))]);
let document=original,versions=new Map([[root,1],[child,1]]),engine;
const transport=async request=>{const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'planner recovery lost expected version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);const participants=Object.keys(body.expected_versions);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>{release();reject(new Error('no durable DFS checkpoint'));},30000);const release=engine.editor.subscribe((_,snapshot)=>{const planner=snapshot.runtime.imports.get(id)?.preparation?.planner;if(planner?.phase==='dfs'&&planner.work.length){clearTimeout(timer);release();resolve();}});});
await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id),saved=JSON.stringify(before.preparation.planner);assert(before.preparation.version===4&&before.preparation.planner.phase==='dfs','did not pause within DFS');assert(before.preparation.planner.sourceToken===before.source.sha256,'checkpoint lost source binding');assert(before.committedSourceKeys.length===0,'preparation published content prematurely');await engine.close();engine=await openProjectEngine(options);
assert(JSON.stringify(engine.imports.getJobs().get(id).preparation.planner)===saved,'IndexedDB changed the DFS checkpoint');let resumed=false;const release=engine.editor.subscribe((_,snapshot)=>{const planner=snapshot.runtime.imports.get(id)?.preparation?.planner;if(planner){assert(planner.steps>=before.preparation.steps,'resume regressed the cursor');assert(!['selection','canvases','members','roots','seed'].includes(planner.phase),'resume repeated an earlier preparation phase');resumed=true;}});
await engine.imports.resume(id);await engine.settle();release();const job=engine.imports.getJobs().get(id);assert(resumed&&job.status==='completed'&&job.failures.length===0,'DFS recovery did not complete');assert(job.committedSourceKeys.length===502&&Object.keys(job.stableIds).length===502,'DFS recovery lost a source record');assert(document.elements.size===1002&&document.canvases.size===3,'DFS recovery duplicated or lost content');const copiedChild=job.stableIds[`canvas:${child}`];for(let i=0;i<500;i++)assert(document.elements.get(job.stableIds[`element:member-${i}`])?.ownerCanvasId===copiedChild,'DFS sibling lost its copied owner');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
async function nativeImportRecoveryCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),original=createDocument(root,[{id:root,parent:null}],[]),database=`${name}-native-import`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1]]),engine;
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
const transport=async request=>{calls.push(request);const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'import lost the version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
const node=id=>({kind:'element',key:id,record:{id,incarnation:5,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:id,text:'',taskId:'not copied'},geometry:{x:0,y:0,width:200,height:100,rotation:0}}}),edge={kind:'element',key:'edge',record:{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'$target',source:{elementId:'first'},target:{elementId:'second'},path:null,data:{}}},text=[{...node('first'),record:{...node('first').record,componentId:'frame'}},edge,{...node('second'),record:{...node('second').record,ownerCanvasId:'child'}},{kind:'canvas',key:'child',record:{id:'child',parent:{canvasId:'$target',entryElementId:'first'}}}].map(value=>JSON.stringify(value)).join('\n')+'\n{bad}';
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);const id=engine.imports.startNdjson(new Blob([text]),root);
await new Promise(resolve=>{const release=engine.editor.subscribe((_,snapshot)=>{if(snapshot.runtime.imports.get(id).committedSourceKeys.length===1){release();resolve();}});});await engine.imports.pause(id);await engine.settle();
const first=engine.imports.getJobs().get(id).stableIds['element:first'],fileId=engine.imports.getJobs().get(id).source.fileId;engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'user edit'}});await engine.settle();await engine.close();engine=await openProjectEngine(options);
assert(engine.imports.getJobs().get(id).status==='paused','paused import did not survive IndexedDB');await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);
assert(job.status==='partial'&&job.committedSourceKeys.length===4&&job.failures.length===1,'partial native import lost progress or failure report');assert(job.stableIds['element:first']===first&&document.elements.get(first).data.title==='user edit','import reallocated an identity or overwrote user content');assert(document.canvases.size===2&&document.elements.size===3&&engine.getSnapshot().runtime.runs.size===0,'native import duplicated an object or launched a run');assert(!Object.hasOwn(document.elements.get(first).data,'taskId'),'native import retained execution metadata');
const leaderRead=await (async()=>{await engine.close();const leader=await storage.claim(options.scope);try{return await storage.readFile(leader,fileId);}finally{await leader.release();}})();assert(await leaderRead.text()===text,'original source bytes were not durably stored');engine=await openProjectEngine(options);
assert(engine.editor.history.undo().status==='applied','import batch undo failed');await engine.settle();assert(document.elements.size===1&&document.elements.has(first),'undo did not preserve the interrupted user edit');await engine.close();engine=await openProjectEngine(options);await engine.settle();assert(document.elements.size===1&&engine.getSnapshot().document.elements.size===1,'terminal job revived records removed by undo');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
async function elisionRecoveryCase(name,assert){
for(const structural of [false,true]){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),database=`${name}-elision-${structural}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1]]),engine;
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'elision restore lost the actual server version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
const reopen=async()=>{await engine.close();engine=await openProjectEngine(options);};
try{
engine=await openProjectEngine(options);const created=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:structural?{...node('temporary'),componentId:'frame'}:node('temporary')});if(structural){tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'temporary'}}});tx.dispatch({type:'element.create',element:node('member',child)});}}),removed=engine.editor.dispatch({type:'element.remove',target:{id:'temporary',incarnation:1}});await engine.settle();
const ids=[...created.operationIds,...removed.operationIds];assert(ids.length===2&&calls.length===0,'closed unsent lifetime still sent a request');assert(engine.getSnapshot().runtime.ledger.requests.size===0&&engine.getSnapshot().history.undo.length===2,'local elision forged a request or collapsed history');
await reopen();for(const id of ids)assert(engine.getSnapshot().runtime.ledger.operations.get(id).resolution==='elided','local completion proof did not survive IndexedDB');assert(engine.getSnapshot().document.tombstones.get('temporary')===1&&!engine.getSnapshot().runtime.acknowledged.tombstones.has('temporary'),'local lifecycle contaminated the server baseline');
const localUndo=engine.editor.history.undo(),localRedo=engine.editor.history.redo();assert(localUndo.status==='applied'&&localRedo.status==='applied','local traversal pair failed');ids.push(...localUndo.operationIds,...localRedo.operationIds);await engine.settle();await reopen();assert(calls.length===0&&engine.getSnapshot().document.tombstones.get('temporary')===2,'repeated local elision lost its next incarnation');
engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'unrelated ACK'}});await engine.settle();const session=await engine.prepareRecovery();assert((await session.apply()).status==='applied','canonical refresh after elision failed');await engine.settle();await reopen();assert(engine.getSnapshot().document.tombstones.get('temporary')===2,'ACK or fresh canonical read lost the local high-water mark');
assert(engine.editor.history.undo().status==='applied','unrelated undo failed');await engine.settle();assert(engine.editor.history.undo().status==='applied','elided deletion undo failed');await engine.settle();assert(document.elements.get('temporary').incarnation===3,'undo reused the canceled incarnation');if(structural)assert(document.canvases.has(child)&&document.elements.get('member').incarnation===3,'partition undo lost its members or lifecycle');
assert(engine.editor.history.redo().status==='applied','elided deletion redo failed');await engine.settle();assert(!document.elements.has('temporary')&&document.tombstones.get('temporary')===3,'redo did not archive the fresh life');await reopen();assert(engine.getSnapshot().document.tombstones.get('temporary')===3,'server/local lifecycle merge regressed after reopening');assert(calls.filter(call=>call.method==='POST').length===4,'elision history was retransmitted');for(const request of engine.getSnapshot().runtime.ledger.requests.values())assert(!request.operationIds.some(id=>ids.includes(id)),'locally elided IDs appeared in a backend request');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
}
async function batchRecoveryCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument(root,[{id:root,parent:null}],[node]);
const firstTitle='first '.repeat(100),secondTitle='second '.repeat(100);
const database=`${name}-batch-recovery`,storage=createBrowserStorage({name:database}),calls=[],receipts=new Map();let server=document,version=1,lose=true,engine;
const transport=async request=>{
calls.push(request);
if(request.method==='GET')return{status:200,body:receipts.get(request.path.split('/').at(-1))};
const body=JSON.parse(request.body);assert(body.expected_versions[root]===version,'batch lost the server version fence');
for(const operation of body.operations)server=applyChanges(server,operation.changes);version++;
const receipt={protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[root]:version}}};receipts.set(request.requestId,receipt);
if(lose){lose=false;return{status:502,body:{error:'lost_batch_ack'}};}return{status:200,body:receipt};
};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);
const first=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:firstTitle}}),second=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:secondTitle}});
assert(engine.getSnapshot().document.elements.get('a').data.title===secondTitle,'local publication waited for batch preparation');await engine.settle();
const frozen=[...engine.getSnapshot().runtime.ledger.requests.values()][0];
assert(calls.length===1&&frozen.operationIds.length===2&&frozen.operationIds[0]===first.operationIds[0]&&frozen.operationIds[1]===second.operationIds[0],'logical edits were not covered by one ordered request');assert(version===2,'batch advanced a canvas version per operation');const wire=JSON.parse(frozen.body).operations,raw=frozen.operationIds.map(id=>({id,changes:engine.getSnapshot().runtime.ledger.operations.get(id).intent.changes}));assert(JSON.stringify(wire).length<JSON.stringify(raw).length,'eligible long field batch was not compacted');assert(wire.at(-1).changes[0].before==='a'&&wire.at(-1).changes[0].after===secondTitle,'wire projection did not retain original before and final after');
await engine.close();engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}});await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
assert(calls.length===2&&calls[1].method==='GET'&&calls[1].path.endsWith(frozen.id),'batch recovery sent a new request');
assert(engine.getSnapshot().runtime.ledger.requests.get(frozen.id).body===frozen.body&&engine.getSnapshot().history.undo.length===2,'batch changed frozen bytes or collapsed history units');
for(const id of frozen.operationIds)assert(engine.getSnapshot().runtime.ledger.operations.get(id).phase==='resolved','batch ACK omitted a logical operation');
assert(engine.editor.history.undo().status==='applied','second edit undo failed');await engine.settle();assert(server.elements.get('a').data.title===firstTitle,'batch undo lost the intermediate historical value');
assert(engine.editor.history.undo().status==='applied','first edit undo failed');await engine.settle();assert(server.elements.get('a').data.title==='a','batch first inverse failed');
assert(engine.editor.history.redo().status==='applied','first edit redo failed');await engine.settle();assert(server.elements.get('a').data.title===firstTitle,'batch redo skipped an independent history unit');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
async function protocolRecoveryCase(name,assert){
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000003',account='10000000-0000-4000-8000-000000000004';
const registry=createRegistry(builtinComponents()),document=createDocument(root,[{id:root,parent:null}],[{id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:260,height:200,rotation:0},data:{title:'a',text:''}}]);
const calls=[],receipts=new Map();let server=document;
const transport=async input=>{
calls.push(input);
if(input.method==='POST'){
const batch=JSON.parse(input.body);server=applyChanges(server,batch.operations[0].changes);
receipts.set(batch.request_id,{protocol_version:2,request_id:batch.request_id,status:'applied',operation_ids:batch.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:2},complete:false,removedCanvasIds:[]}});
return{status:502,body:{error:'upstream_timeout'}};
}
return{status:200,body:receipts.get(input.path.split('/').at(-1))};
};
const storage=createBrowserStorage({name:`${name}-protocol`}),persistence=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport});
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document,versions:new Map([[root,1]]),storage,persistence};let engine;
try{
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'durable-v2'}});await engine.settle();
const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(request.protocolVersion===2,'new protocol metadata was replaced by a legacy default');await engine.close();
engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(callback,delay)=>setTimeout(callback,delay),clearTimer:handle=>clearTimeout(handle)}});
await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
assert(calls.length===2&&calls[1].method==='GET','protocol 2 recovery must query the original request');
assert(engine.getSnapshot().document.elements.get('a').data.title==='durable-v2'&&engine.getSnapshot().history.undo.length===1,'protocol recovery changed working content or duplicated history');
const recovered=engine.getSnapshot().runtime.ledger.requests.get(request.id);assert(recovered.body===request.body&&recovered.outcome==='applied','protocol recovery rewrote request bytes or lost the receipt');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-protocol`)}
}
async function engineRecoveryCase(name, assert) {
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' }, registry = createRegistry(builtinComponents());
const document = createDocument('project', [{ id: 'canvas', parent: null }], [{ id: 'element', incarnation: 1, ownerCanvasId: 'canvas', componentId: 'text', dataVersion: 1, structure: 'node', geometry: { x: 0, y: 0, width: 100, height: 100, rotation: 0 }, data: { title: 'initial', text: '' } }]);
let server = document; const receipts = new Map(), calls = [];
const port = {
compile(intent, requestId, snapshot) { return { payload: { intent, requestId }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(snapshot.runtime.versions) }; },
async submit(request) { calls.push(['submit', request]); const intent = JSON.parse(request.body).intent; server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['canvas', 2]]) }); return { outcome: 'unresolved' }; },
async query(request) { calls.push(['query', request]); return { outcome: 'applied', canonical: { receiptId: request.id } }; },
canonical(request) { return receipts.get(request.id); },
};
const storage = createBrowserStorage({ name: `${name}-engine`, checkpointEvery: 4 }), options = { scope, registry, document, versions: new Map([['canvas', 1]]), storage, persistence: port };
let engine;
try {
engine = await openProjectEngine(options);
const detach = engine.sessions.attach('view');
engine.editor.dispatch({ type: 'element.data', target: { id: 'element', incarnation: 1 }, patch: { title: 'durable edit' } });
detach(); await engine.settle();
assert(calls.length === 1, 'detaching last view must not abort persistent work');
const request = calls[0][1];
await engine.close();
const futureClock = { now: () => Date.now() + 100_000, setTimer: (callback, delay) => setTimeout(callback, delay), clearTimer: handle => clearTimeout(handle) };
engine = await openProjectEngine({ ...options, clock: futureClock });
await new Promise(resolve => setTimeout(resolve, 10)); await engine.settle();
assert(calls.length === 2 && calls[1][0] === 'query', 'recovered dispatched request must query before a new submit');
assert(calls[1][1].id === request.id && calls[1][1].body === request.body, 'request ID/body must survive real IndexedDB recovery exactly');
assert(engine.getSnapshot().document.elements.get('element').data.title === 'durable edit', 'recovered pending changes must not replay twice');
assert(engine.getSnapshot().history.undo.length === 1 && engine.getSnapshot().operations.size === 1, 'history and ledger must recover the same state cut');
assert(engine.sessions.getSnapshot().views.size === 0, 'transient views must not be recovered as document data');
assert(engine.getDurability().rootRevision === engine.getDurability().durableRootRevision, 'final receipt and replay must cross the durable barrier');
} finally { await engine?.close(); storage.close(); indexedDB.deleteDatabase(`${name}-engine`); }
}
async function loadingImportRecoveryCase(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:5,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-import',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:[]}}};}});
const options={scope,registry,storage,persistence,document:prefix,versions:new Map(versions),loading};
try{engine=await openProjectEngine(options);assert(!engine.imports.canStart(root).enabled&&engine.imports.canStart(child).enabled,'import admission ignored coverage');const id=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'imported',record:node('imported','$target')})+'\n']),child);await engine.imports.pause(id);await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(engine.imports.getJobs().get(id).status==='paused','paused v11 task did not reopen');await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id),mapped=job.stableIds['element:imported'];assert(job.status==='completed'&&server.elements.has(mapped),'recovered source bytes did not import before tail');assert(engine.loading.getState().version===5,'import replaced the partial read');await engine.close();engine=await openProjectEngine(options);assert(engine.imports.getJobs().get(id).stableIds['element:imported']===mapped,'reopening reallocated imported identities');assert(engine.editor.history.undo().status==='applied','partial import history did not reopen');await engine.settle();assert(!server.elements.has(mapped),'partial import undo left a server object');assert(calls.length===2,'import recovery submitted duplicate operations');
}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};
const adapter=createBrowserStorage({name:`${name}-loading`,checkpointEvery:3});let ownerForTest;const storage={...adapter,async claim(scope){ownerForTest=await adapter.claim(scope);return ownerForTest;}};const calls=[],receipts=new Map();let server=full,version=1,engine;
const model=(complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases,elements:[...server.elements.values()],tombstones:{},versions:{[root]:version},removedCanvasIds:[]});
const transport=async input=>{if(input.method==='GET')return{status:200,body:{model:model()}};calls.push(input);const body=JSON.parse(input.body);assert(body.expected_versions[root]===version,'loading writer used a stale version');server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));version++;const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)};receipts.set(input.requestId,receipt);return{status:200,body:receipt};};
const persistence=createFoundryPersistence({registry,workspaceId:scope.workspaceId,rootId:root,transport}),open=()=>openProjectEngine({scope,registry,document:prefix,versions,loading,storage,persistence,createId:()=>crypto.randomUUID()});
try{engine=await open();const target={id:'A',incarnation:1};assert(engine.editor.dispatch({type:'element.data',target,patch:{text:'local before tail'}}).status==='applied','loading field rejected');await engine.settle();assert(calls.length===0&&engine.getSnapshot().runtime.ledger.requests.size===0,'loading prefix froze or sent a request');await engine.close();
const owner=await storage.claim(scope),image=await storage.recover(owner);assert(image.formatVersion===format,'loading state did not require its versioned recovery envelope');decodeRecovery(image,registry);await owner.release();engine=await open();assert(engine.getSnapshot().document.elements.get('A').data.text==='local before tail','loading edit lost after IndexedDB reopen');assert(engine.editor.history.undo().status==='applied'&&engine.editor.history.redo().status==='applied','loading history did not reopen');await engine.settle();assert(calls.length===0,'history traversal escaped the loading hold');
const before=engine.getDurability().durableRootRevision,nativePut=IDBObjectStore.prototype.put;let injected=false,failure;
IDBObjectStore.prototype.put=function(value,key){const request=nativePut.call(this,value,key);if(!injected&&this.name==='checkpoints'&&(value?.image?.formatVersion??value?.header?.formatVersion)!==format){injected=true;throw Error('loading_full_cut_abort');}return request;};
try{await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.put=nativePut;}
assert(injected&&failure==='loading_full_cut_abort','completion did not reach forced checkpoint rollback');const partial=await adapter.recover(ownerForTest);assert(partial.rootRevision===before&&partial.formatVersion===format&&calls.length===0,'aborted full checkpoint released loading writes or lost the partial cut');
await engine.persist();await engine.settle();
assert(!engine.loading.getState()&&server.elements.get('A').data.text==='local before tail'&&engine.getSnapshot().document.elements.size===2,'full tail overwrote the local prefix');await engine.close();engine=await open();await engine.settle();assert(!engine.loading.getState()&&engine.getSnapshot().document.elements.size===2,'completed recovery reopened as partial');
const database=await new Promise((resolve,reject)=>{const request=indexedDB.open(`${name}-loading`);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
try{const transaction=database.transaction(['checkpoints','journal'],'readonly'),read=store=>new Promise((resolve,reject)=>{const request=transaction.objectStore(store).getAll();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const cuts=(await Promise.all([read('checkpoints'),read('journal')])).flat();assert(cuts.every(cut=>(cut.image?.formatVersion??cut.header?.formatVersion)<7),'completed storage still requires a partial journal base');}finally{database.close();}
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-loading`);}
}
async function scopedAckRecoveryCase(name,assert,structural=false){
name+=structural?"-structural":"-fields";
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:'original'},geometry:{x:0,y:0,width:120,height:80,rotation:0}}),a=node('A'),b=node('B'),f={...node('F'),componentId:'frame'},c=node('C',child),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'C'},path:null},canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],prefix=createDocument(root,canvases,[a,f,c]),full=createDocument(root,canvases,[a,b,f,c,edge]),versions=new Map([[root,1],[child,1]]),loading={version:structural?4:3,...(structural?{elementOwners:{A:root,B:root,F:root,C:child,E:root}}:{}),sourceToken:'scoped-browser-source',declaredCanvasIds:[root,child],receivedElements:3,totalElements:5,canvasElementCounts:{[root]:4,[child]:1}},storage=createBrowserStorage({name:name+'-scope-ack'}),receipts=new Map(),bodies=[];
let server=full,currentVersions=new Map(versions),time=0,engine;
const model=(participants,complete=false)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...server.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...server.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:{},versions:Object.fromEntries(participants.map(id=>[id,currentVersions.get(id)])),removedCanvasIds:[]});
const transport=async input=>{if(input.method==='GET')return{status:200,body:receipts.get(input.path.split('/').at(-1))};const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);bodies.push(input.body);for(const id of participants)assert(body.expected_versions[id]===currentVersions.get(id),'scoped request used a stale owner version');server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));for(const id of participants)currentVersions.set(id,currentVersions.get(id)+1);const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(participants)};receipts.set(input.requestId,receipt);if(bodies.length===1)throw Error('lost scoped ACK');return{status:200,body:receipt};};
const persistence=createFoundryPersistence({registry,workspaceId:scope.workspaceId,rootId:root,transport}),clock={now:()=>time,setTimer:()=>0,clearTimer(){}},open=()=>openProjectEngine({scope,registry,document:prefix,versions,loading,storage,persistence,clock});
try{engine=await open();engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'root waits'}});engine.editor.dispatch(structural?{type:'element.create',element:{...node('born-browser',child),data:{title:'Born',text:'child applied'}}}:{type:'element.data',target:{id:'C',incarnation:1},patch:{text:'child applied'}});await engine.settle();assert(bodies.length===1&&server.elements.get(structural?'born-browser':'C')?.data.text==='child applied'&&server.elements.get('A').data.text==='original','incomplete owner blocked another certified owner or sent prematurely');const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];await engine.close();time=1000;engine=await open();await engine.settle();assert(bodies.length===1&&engine.getSnapshot().runtime.ledger.requests.get(request.id).body===request.body,'reopen repeated the lost-ACK request');assert(engine.getSnapshot().runtime.versions.get(child)===2&&engine.getSnapshot().runtime.loadingRead.versions.get(child)===1,'read and ACK baselines did not recover independently');
assert(engine.loading.accept(full,versions,{...loading,receivedElements:5}).status==='applied','old source tail did not reconcile');await engine.settle();assert(bodies.length===2&&server.elements.get('A').data.text==='root waits','newly complete owner did not wake');assert((await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken})).status==='applied','original source could not complete after scoped ACKs');await engine.settle();assert(engine.getSnapshot().runtime.loadingRead===undefined&&engine.getSnapshot().document.elements.get(structural?'born-browser':'C')?.data.text==='child applied'&&engine.getSnapshot().document.elements.get('A').data.text==='root waits','old completion replaced ACKs or retained the source');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(name+'-scope-ack');}
}
// 串行执行整套浏览器组合检查;只有 helper 返回或本地断言通过才追加对应结果,任何失败都会拒绝整个 run。
export async function run() {
// 随机前缀隔离各 helper 的数据库;总入口另持有基础存储 adapter 与独占 leader。
const results = [], name = `headless-test-${crypto.randomUUID()}`;
const scope = { accountId: 'test-account', workspaceId: 'test-workspace', projectId: 'test-project' };
const storage = createBrowserStorage({ name, checkpointEvery: 3 });
// 基础存储用最小自定义 payload 测序列化和版本,不把它当成经过 decodeRecovery 的真实业务文档。
const image = revision => ({ formatVersion: 1, rootRevision: revision, localRevision: Math.min(revision, 1), payload: { document: { title: `v${revision}` }, ledger: { frozenBody: '{"request_id":"stable"}' }, history: ['history-id'] } });
const assert = (condition, message) => { if (!condition) throw new Error(message); };
// 要求异步失败的 message 精确匹配;若未抛错也直接失败,避免静默降级被当作成功。
const rejects = async (callback, message) => { try { await callback(); } catch (error) { assert(error.message === message, `expected ${message}, received ${error.message}`); return; } throw new Error(`expected ${message}`); };
let leader, other;
try {
// 先运行独立 depot/Blob/升级 helper,覆盖真实浏览器事务和锁;各 helper 管理自己的数据库生命周期。
await depotAdoptionRaceCase(assert);results.push('real IndexedDB GC and a gated older Root cannot consume or release bytes belonging to a newer adoption cut');
await depotFilesCase(assert);results.push('stored uploads use shared-byte file pointers with original MIME, atomic promotion rollback, logical inventory and independent identity eviction/rehydration');
await blockedUpgradeCase(assert);results.push('blocked upgrades abort abandoned opens without upgrading after failure or leaking a connection');
await blobCheckpointCase(assert);results.push('DB v4 legacy checkpoints upgrade to v5 Blob cuts with exact bytes, stale-reader rejection and unchanged request bodies');results.push('Blob cut corruption, future format and atomic-file rollback preserve the previous durable Root');
await depotFilesUpgradeCase(assert);results.push('DB v3 original files/checkpoints upgrade to v5 and lazily promote through an idempotent Root cut');
await assetAdoptionStorageCase(assert);results.push('cross-project adoption uses fresh authorization, survives workspace/IndexedDB reopening and undo/redo, and preserves closed destination pins without project-local byte copies');
await assetDepotStorageCase(assert);results.push('workspace depot atomically shares digest bytes, preserves closed-project pins, blocks unknown/stale coverage and rolls back failed Root cuts');
// 空 scope 恢复必须返回 null,同时重复 leader 被拒绝;首个 checkpoint 与 Blob 文件一起保存。
leader = await storage.claim(scope);
assert(await storage.recover(leader) === null, 'empty scope must be distinguishable from a corrupt checkpoint');
await rejects(() => storage.claim(scope), 'storage_scope_in_use');
await storage.commit(leader, { image: image(0), files: [{ id: 'asset', bytes: new Blob(['original']) }] });
assert((await storage.recover(leader)).rootRevision === 0, 'initial checkpoint should recover');
assert(await (await storage.readFile(leader, 'asset')).text() === 'original', 'committed bytes should survive');
results.push('initial checkpoint and file bytes commit together; duplicate leader rejected');
// 相同切点可幂等重试;同修订内容冲突、跨修订跳号及未来恢复格式均不可接受。
await storage.commit(leader, { image: image(1) });
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: 18 } }), 'unsupported_recovery_version');
results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject');
// 后一个资源身份冲突使整批 abort:前一个新文件、Root 和现有文件不可出现半提交。
await rejects(() => storage.commit(leader, { image: image(2), files: [{ id: 'orphan', bytes: new Blob(['must-rollback']) }, { id: 'asset', bytes: new Blob(['replacement']) }] }), 'resource_identity_conflict');
assert(await storage.readFile(leader, 'orphan') === undefined, 'aborted resource bytes must roll back');
assert((await storage.recover(leader)).rootRevision === 1, 'aborted document/history/ledger cut must not advance');
assert(await (await storage.readFile(leader, 'asset')).text() === 'original', 'existing resource bytes must remain immutable');
results.push('late transaction failure rolls back new bytes and the entire state cut');
await storage.commit(leader, { image: image(2) });
// 释放 leader 后由新 adapter 接管,fence 必须递增;旧句柄不能写,journal 可恢复并压实为新 checkpoint。
const originalFence = leader.fence;
await leader.release();
other = createBrowserStorage({ name, checkpointEvery: 3 });
const next = await other.claim(scope);
await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_leader_lost');
assert(next.fence > originalFence, 'reacquire must advance fencing generation');
assert((await other.recover(next)).rootRevision === 2, 'new adapter must replay the durable journal');
await other.commit(next, { image: image(3) });
assert((await other.recover(next)).payload.document.title === 'v3', 'compacted checkpoint must cover the same document/history/ledger cut');
leader = next;
results.push('fresh adapter replays journal; released handle is fenced; checkpoint compaction preserves latest state');
// 先篡改再删除 journal 行,分别验证 checksum 与缺口错误;损坏绝不能被解释为空 scope。
await other.commit(next, { image: image(4) });
const database = await new Promise((resolve, reject) => { const request = indexedDB.open(name); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
const storageKey = [JSON.stringify([scope.accountId, scope.workspaceId, scope.projectId]), 4];
// 直接篡改持久 journal 行的有效 JSON 而不更新 checksum,先验证字节校验,再删除该行验证连续性。
const intact=await new Promise((resolve,reject)=>{const request=database.transaction('journal').objectStore('journal').get(storageKey);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);}),corrupted=structuredClone(intact);
if(corrupted.image)corrupted.image.payload.document.title='altered';else{const image=JSON.parse(await corrupted.bytes.text());image.payload.document.title='altered';corrupted.bytes=new Blob([JSON.stringify(image)]);}
const tamper=database.transaction('journal','readwrite');tamper.objectStore('journal').put(corrupted,storageKey);
await new Promise((resolve,reject)=>{tamper.oncomplete=resolve;tamper.onabort=()=>reject(tamper.error);});
await rejects(() => other.recover(next), 'recovery_checksum_mismatch');
results.push('well-formed data tampering is detected by the stored checksum');
// 真正删除 revision 4 日志并等待事务完成,后续 recover 必须报 journal gap,而不是返回空文档。
const transaction = database.transaction('journal', 'readwrite');
transaction.objectStore('journal').delete(storageKey);
await new Promise((resolve, reject) => { transaction.oncomplete = resolve; transaction.onabort = () => reject(transaction.error); });
database.close();
await rejects(() => other.recover(next), 'recovery_journal_gap');
results.push('journal gap is reported as corruption and never converted to an empty document');
// 以下 helper 使用同一随机命名前缀各自建库,串行验证引擎、资源回收、上传和渐进来源的持久恢复。
await engineRecoveryCase(name, assert);
results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit');
await atomicResourceDeletionCase(name,assert);
results.push('atomic local file collection rolls back injected failure after IDB delete, captures descriptors, rejects missing markers and survives reopening');
await importSourceCollectionCase(name,assert);
results.push('terminal import source GC atomically rolls back a post-delete fault, retries its v4 cut, preserves partial reports and reopens with undo/redo independent of source bytes');
await orphanInventoryCase(name,assert);
results.push('scoped paged inventory finds legacy orphans without hiding references; orphan v5 deletion rolls back faults, retries, isolates projects and forbids byte identity reuse');
await resourceRecoveryCase(name, assert);
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
await loadingRecoveryCase(name,assert);
results.push('recovery v7 preserves loaded-field edits/history in IndexedDB; an injected full-checkpoint failure retains the partial cut and zero writes, then retry removes v7 journal dependencies before releasing requests');
await loadingRecoveryCase(name,assert,true);
results.push('recovery v8 preserves certified scope coverage and loading edits; full-checkpoint fault rolls back atomically, retry retires all partial journal dependencies before sending');
await loadingRecoveryCase(name,assert,true,true);
results.push('recovery v9 retains the immutable read baseline and defers incomplete owners; full-checkpoint failure preserves the partial source/ledger and retry retires its journal before writes');
await scopedAckRecoveryCase(name,assert);
results.push('scoped v9 requests survive lost ACK and IndexedDB reopening with exact body/key; querying confirms the original child write, old tail wakes the parent and completion preserves both advanced owners');
await loadingRecoveryCase(name,assert,true,true,true);
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');
// 这组单独验证真实 fetch 流、图片/摘要 Worker、显示借用、输入所有权和主线程协作;不是后端集成。
await scopedNetworkLoaderCase(assert);
results.push('scoped loader shares one real streamed fetch, exposes the first chunk before tail release, keeps the other subscriber alive after cancellation and preserves partial data after a malformed tail');
await defaultImageSizeCase(name, assert);
results.push('real image Worker enriches creation history in recovery v6; IndexedDB reopening queries a lost size ACK; concurrent views share one read/URL, retain leases incrementally and release only the final display pin before sized undo/redo');
await imageDecodeWorkerCase(assert);
results.push('real module image workers decode intrinsic PNG dimensions with bounded concurrency, reject bad bytes, abort safely and release their bitmap workers');
await digestWorkerCase(assert);
results.push('bounded real module workers hash chunked Blobs with native SHA256 parity, queued abort and close terminate work without a late result');
await ownedImageStorageCase(name,assert);
results.push('owned immutable recovery images survive IndexedDB checksums; mutable images and file descriptors are captured before asynchronous work');
await cooperativeCopyCase(assert);
results.push('cooperative duplicate planning yields to actual browser tasks and cancellation without mutating the source');
// 随后继续验证导入/复制来源、日志链、背压、抵消及协议恢复;每条英文结果仍由相邻 helper 成功返回后追加。
await sourceIntegrityCase(name,assert);
results.push('source SHA256 detects same-length Blob tampering before recovery adds or sends a tail; exact bytes resume with the original prefix and IDs');
await deltaJournalCase(name,assert);
results.push('delta journals migrate legacy checkpoints, retain atomic files, verify hashes/base chains, reject old readers and compact through serialized immutable commits');
await importBackpressureCase(name,assert);
results.push('blocked source durability permits a bounded local prefix, fences all network writes and recovers all records after real IndexedDB resumes');
await referenceCycleCase(name,assert);
results.push('native frame reference cycles and copied port mappings commit atomically through protocol 2 and real IndexedDB undo/redo/reopening');
await duplicatePlannerRecoveryCase(name,assert);
results.push('preparation v4 persists a DFS stack/cursor in real IndexedDB and resumes 500 siblings without restarting scans or duplicating identities');
await duplicateRecoveryCase(name,assert);
results.push('deep copy source/mapping survive IndexedDB pause/reopen/resume, retain C to F-prime and shared assets, preserve edits and do not revive undone content');
await nativeImportRecoveryCase(name,assert);
results.push('native import source/cursor/IDs survive real IndexedDB pause/reopen/resume, preserve user edits, report partial failures and do not revive undone records');
await elisionRecoveryCase(name,assert);
results.push('unsent node/partition create-delete lifetimes make zero requests, retain local proofs through IndexedDB/ACK/fresh reads, and undo restores new incarnations');
await batchRecoveryCase(name,assert);
results.push('ordered mutable batches retain every logical ID and independent undo unit through a lost ACK, real IndexedDB reopening and exact receipt query');
await protocolRecoveryCase(name,assert);
results.push('protocol 2 service adapter recovers exact frozen bytes and reconciles a lost ACK through real IndexedDB');
await runRecoveryCase(name,assert);
results.push('AI admission and RunRecord recover through real IndexedDB; cursor and generated content share a durable cut without a second invocation');
// 最后一组检查规范刷新、冲突替代、已成功回执及复杂历史证据的真实 IndexedDB 重开。
await canonicalRefreshCase(name,assert);
results.push('quiescent canonical refresh preserves original requests and history; the new base/document/version cut survives IndexedDB reopening');
await conflictReplanCase(name,assert);
results.push('conflict replan persists its audit/history/new identity; a lost replacement ACK recovers by the exact key and undo restores the reviewed server value');
await appliedReceiptRecoveryCase(name,assert);
results.push('a persisted successful receipt with failed coordination adopts a fresh base and replan in one cut, releases its writer and never repeats the original write');
await structureRecoveryCase(name,assert);
results.push('structural replan restores an atomic partition/member tree through IndexedDB, undo/archive and redo/new incarnation without changing the rejected request');
await scaledContainerCase(name,assert);
results.push('scaled container units preserve nested transforms and typography through atomic protocol writes, IndexedDB reopening, undo and redo');
await mixedHistoryCase(name,assert);
results.push('mixed acknowledged entry/pending partition history persists one undo unit, preserves original request facts and restores fresh lifecycles after reopening');
await traversalRecoveryCase(name,assert);
results.push('rejected partition undo recovers archived scopes and history through IndexedDB; redo/undo advance lifecycles without repeating the old request');
await historyEvidenceCase(name,assert);
results.push('history transition evidence survives redo invalidation and IndexedDB reopening, reconstructs prior stacks without document mutation and never enters service payloads');
await journalRecoveryCase(name,assert);
results.push('native history events recover a rejected undo after a later confirmed branch cleared redo; IndexedDB reopening retains the branch identity and sends its content once');
await userJournalRecoveryCase(name,assert);
results.push('pure user group recovery preserves a later acknowledged contribution across keep/discard, IndexedDB reopening and atomic undo/redo without retransmission');
await confirmedTraversalCase(name,assert);
results.push('later confirmed undo/redo restorations replay history only, preserve request bytes through reopening and remap older and remaining history lifecycles');
await adoptionEvidenceCase(name,assert);
results.push('adoption anchor and event evidence survive IndexedDB reopening and compose mixed geometry/content choices exactly without extra wire writes');
await adoptionConsumerCase(name,assert);
results.push('adoption evidence is consumed across repeated recovery/reopening, preserving old requests, latest reviewed field baselines and structural lifecycles');
// 整个串行检查完成才返回结果;finally 释放当前 leader、关闭 adapter 并发起删库,不等待删除事件。
return results;
} finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); }
}