1198 lines
213 KiB
JavaScript
1198 lines
213 KiB
JavaScript
/**
|
||
* 由隔离浏览器 runner 调用的存储/引擎组合场景。IndexedDB、Web Locks、Blob URL 和 Worker 使用浏览器实现,
|
||
* 大多数文档/上传服务为内存替身;网络流场景只连接本地可控 fixture,不代表远程 Foundry 数据库验证。
|
||
* 持久化场景使用独立 scope/数据库,显示借用等 helper 也可复用传入引擎;run 串行编排,通过断言后才追加结果。
|
||
* 版本号需区分:数据库 schema、checkpoint/journal 编码、recovery 镜像、loading 描述和服务协议分别演进。
|
||
* engine 的“可见”指工作模型已发布,显示借用检查 Blob URL;本文件未挂载 renderer,不凭模型状态证明屏幕已绘制。
|
||
*/
|
||
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 读取当前 ACK 版本作为条件,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 解码峰值为二,坏字节或中止都不能留下未终止的句柄。
|
||
// active 在首次 terminate 调用时减一;它验证客户端调用了清理 API,不是浏览器内部线程已经退出的直接测量。
|
||
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`);}
|
||
}
|
||
|
||
// 已无未决写入时采用更新的完整规范基线:预览不发布,apply 后文档、版本和原历史一起持久化,重开不重发已确认写入。
|
||
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}});
|
||
// 服务替身只应用 changes 并递增版本,不在此重复验证所有条件协议;重点是客户端刷新后的持久化。
|
||
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();
|
||
// 保存已确认编辑的历史 ID 与原冻结请求,随后模拟外部几何修改并推进到版本 3。
|
||
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++;
|
||
// 恢复读是预览,working.x 仍为 0,undo 被恢复屏障拒绝;apply 才发布 x=400。
|
||
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();
|
||
// 重开核对标题、几何、版本及同一历史 ID;请求表/body 不变且只发过最初一次 POST。
|
||
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`);}
|
||
}
|
||
|
||
// AI 受理成功但响应丢失后,真实 IndexedDB 重开先查询原受理,再消费事件;游标与生成内容必须处于同一持久化切点。
|
||
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;
|
||
// 生成单根局部模型供受理版本 2、结果版本 3 使用;服务任务本身仍为内存替身。
|
||
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);
|
||
// 受理先构造 AI 配置、冻结计划和已生成结果,但返回 502,不能在客户端据此直接发布 RunRecord。
|
||
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'}};
|
||
}
|
||
// 按原请求查询时返回保存的 accepted;其他 GET 模拟带序号的 queued/applied 事件页。
|
||
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{
|
||
// 最后一个 view detach 不停止后台受理;等待后 runs 仍应为空,不能猜测丢失响应的身份。
|
||
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();
|
||
// 前移恢复时钟并有界等待 cursor=2;POST 仍只有一次,必须查过原 admission 请求。
|
||
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();
|
||
// 关闭 engine 后直接取得独占 owner 读镜像,检查游标与文字都已落盘;这比只看内存结果更强。
|
||
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`);}
|
||
}
|
||
|
||
// StoragePort 原子文件删除:purge marker、Root 与文件字节一起提交,删除请求入队后注入异常必须整体回滚,并且隔离 project。
|
||
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;
|
||
// 使用最小 v3 恢复 payload 直接测试存储删除约束,不把它当作完整业务模型。
|
||
const marker={version:1,unreferencedAt:100,purgedAt:200},image={formatVersion:3,rootRevision:1,localRevision:0,payload:{runtime:{resources:[['asset',{retirement:marker}]]}}};
|
||
try{
|
||
// 两个 project 允许同名 asset;只在当前 scope 回收,不能影响另一 owner。
|
||
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'])}]});
|
||
// 保存原生 delete,临时在真实删除请求排入事务后抛错;finally 恢复原型,防止污染后续场景。
|
||
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;}
|
||
// 必须确实命中故障;原 asset 仍可读且 Root 保持 0,证明事务没有半提交。
|
||
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');
|
||
// 调用 commit 后立即把传入删除列表改为 keep;实现必须已捕获 asset,不能误删后来的列表项。
|
||
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']});
|
||
// 无匹配 purge marker 的 keep 删除被拒绝;同一已完成切点可重试,重开保持删除且另一项目字节不变。
|
||
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);}
|
||
}
|
||
|
||
// 终态 partial 导入的源 Blob 可在保留期后清理,但失败报告、已提交内容及 undo/redo 不能依赖已退休的原始源文件。
|
||
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)};
|
||
// 包装 commit 捕获当前 owner,仅供测试随后直接读真实 IndexedDB 文件和镜像。
|
||
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{
|
||
// 先导入合法一项再遇非法 JSON,记录 partial 作业源 fileId 和当前文档/history 引用。
|
||
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;
|
||
// 先软标记再推进时间到保留期,随后包装 IDB delete 注入一次事务内故障。
|
||
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;}
|
||
// 删除失败后源字节仍在,v17 镜像只能含软标记,不能提前写 purgedAt。
|
||
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');
|
||
// persist 重试原清理切点,字节删除与 purgedAt=1100 同时恢复;文档/history 引用必须不变。
|
||
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');
|
||
// 低版本解释器不得接受源退休语义;重开保持原 partial 报告,openSource 明确拒绝,undo/redo 仍可操作已导入内容。
|
||
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);}
|
||
}
|
||
|
||
// 盘点未登记文件时仍尊重元素引用,分页只读;孤立字节回收有保留期、原子 purge、项目隔离及持久化身份退休。
|
||
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;
|
||
// 所有文档网络方法故意抛错,确保盘点/回收不偷偷走业务同步;claim 包装只捕获测试 owner。
|
||
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);
|
||
// 两页盘点按 ID 有序且 Root 引用不变;即使资源表未登记,z-pinned 仍必须有 pin。
|
||
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');
|
||
// 软标记后等满 100,再于孤立文件 delete 请求入队后注入故障。
|
||
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;}
|
||
// 失败只保留 v5 软标记和字节;persist 重试才删除孤立项,被引用文件与其他项目文件都保留。
|
||
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');
|
||
// 解码 purgedAt 后再用旧 rootRevision 扫描,必须拒绝过期 inventory,不能基于旧引用集合继续清理。
|
||
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');
|
||
// 即使绕过 engine 直接 commit 也不能复用已退休 ID;重开显示该 ID 应报 local_file_retired,不猜成远程资产。
|
||
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);}
|
||
}
|
||
|
||
// 本地图片先显示,上传结果未知时文档依赖等待;真实 IndexedDB 重开查询同一资源请求,回收后仍可使用其已确认远程身份。
|
||
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 = [];
|
||
// 真实 Foundry 资产适配器接内存 transport:检查 FormData 原文件文本,保存成功回执后故意返回 502。字节不是可解码 PNG。
|
||
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;
|
||
// 文档替身只在上传已确认后应用图片创建;writes 数量用于给规范回执计算版本。
|
||
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]]) }),
|
||
};
|
||
// historyLimit=1 让后面可有意挤掉旧撤销单元,检验资源什么时候真正失去历史 pin。
|
||
const options = { scope, registry, document, versions: new Map([[scope.projectId, 1]]), storage, persistence, assets,historyLimit:1 };
|
||
let engine, resource;
|
||
try {
|
||
engine = await openProjectEngine(options);
|
||
// 同一个本地事务暂存 Blob 与图片引用;用户无需等待上传才看到元素和本地预览。
|
||
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();
|
||
// 上传只发一次且文档尚未发;关闭后直接读取 IndexedDB 核对文件、资源元数据与元素同一切点。
|
||
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');
|
||
// 删除图片后仍由 undo 持有资源,不能立即回收;再创建文本使受限历史淘汰旧单元,才允许清理字节。
|
||
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();
|
||
// 直接检查 v3 purge marker 和文件不存在是否一致,之后重开应回退到原有远程资产 URL,不能重新上传。
|
||
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`); }
|
||
}
|
||
|
||
|
||
// 导入源按 SHA-256 绑定精确字节;等长替换也应在恢复尾部前被拒绝,保留已提交前缀及稳定 ID,原字节还原后继续同一作业。
|
||
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;
|
||
// 服务替身按参与版本应用增量,calls 计数用来证明发现源篡改前没有新增网络工作。
|
||
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)}}};};
|
||
// 两条来源记录使用 $target 映射;first 已提交后暂停,last 尚待恢复。
|
||
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{
|
||
// 等一个 committedSourceKey 后 pause 并核对源摘要已存在,再关闭引擎保留恢复点。
|
||
// 摘要断言发生在 settle 后,不证明首项发布前就算完 SHA;读取证明允许早发布,完整 SHA 封存控制后端发送。
|
||
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;
|
||
// 原始 IDB 连接定位作用域内 source.fileId,绕过正常存储不可变规则以制造损坏。
|
||
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];
|
||
// 把 last 改为等长 evil,核对 Blob 大小不变;resume 必须报告 import_source_changed,不能靠 byteLength 放行。
|
||
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');
|
||
// 修复为原 Blob 后重用同一 task/first 映射继续,只补未完成尾部。
|
||
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);}
|
||
}
|
||
|
||
// 真实 IndexedDB 的差量日志链:迁移旧 checkpoint,小修改只散列差量,基线校验与版本链完整,压实时再散列完整镜像。
|
||
async function deltaJournalCase(name,assert){
|
||
// 大部分 rows 固定,只有第一行随 revision 变化,冻结请求字节不变,用来观察差量大小。
|
||
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'}}});
|
||
// done 等事务完成/abort,read 等单条请求结果;两者职责不同,不能用 put 成功代替事务成功。
|
||
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);});
|
||
// 手工建 DB v1 和旧对象 checkpoint,checksum 覆盖旧完整 JSON,保证实际经历兼容迁移。
|
||
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');
|
||
// 临时包装真实 SubtleCrypto 只记录哈希输入长度;首个小改动应只散列一次差量,finally 恢复 API。
|
||
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);});
|
||
// 直接读 journal row,要求 journalVersion=2、没有 full image 且长度小于样例全镜像十分之一。
|
||
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');
|
||
// 新文件先加入、后一个既有 ID 冲突时整批回滚;修复后连写两步并重建 adapter,验证差量链可重放。
|
||
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]));
|
||
// 构造旧 v1 差量与新 v2 链接混合,按各自规则重算 checksum,检查混合链兼容。
|
||
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});
|
||
// 真实写入混合日志后恢复应精确等于 image(2),随后还原标准日志供独立损坏场景。
|
||
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);
|
||
|
||
// 逐个替换 revision 2 日志,分别损坏 patch、header、baseChecksum 和编码版本;每轮检查对应错误后还原。
|
||
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);}
|
||
// revision 3 触发完整 checkpoint,要求散列完整编码字节且旧 journal 被压实清空。
|
||
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');
|
||
// 恢复结果的调用方修改不能改缓存基线;两个并发 commit 串行捕获,各自使用调用时镜像,晚改第五版不生效。
|
||
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)});
|
||
// 相同修订需精确序列化一致;仅调换 payload 属性顺序也不能当作同切点幂等重试。
|
||
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');
|
||
// 额外 header 字段无法无损差量时使用完整 Blob 行,字段与属性顺序都应保留。
|
||
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');
|
||
// 手工删掉 durable head 后不能凭内存缓存继续写;失败不推进基线,还原 metadata 后仍恢复 revision 7。
|
||
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');
|
||
// 当前数据库版本高于 1,显式用 schema 1 打开必须得到浏览器 VersionError。
|
||
// 这验证 IDB 版本门禁;journalVersion=99 的解释器拒绝由上面的 future 日志损坏用例单独验证。
|
||
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);}
|
||
}
|
||
|
||
// 源 Blob 持久化被阻挡时,渐进导入只可推进有界本地前缀,网络保持零发送;解锁后完成并经 IndexedDB 重开验证全部进度。
|
||
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;});
|
||
// 内存服务仍检查每个参与版本,calls 作为网络发送屏障的观测点。
|
||
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)]))}}};};
|
||
// 只在 commit 批次含文件时等待 gate,刻意阻挡源文件落盘,而不是暂停所有主线程工作。
|
||
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{
|
||
// 500 条记录用于触及未持久 Root 预算;初始引擎状态先 persist,排除初始化脏状态干扰。
|
||
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);
|
||
// 等 rootRevision 与 durableRootRevision 差至少 32,5 秒内没触及预算则失败;订阅和超时都在完成时清理。
|
||
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);});
|
||
// 前缀必须多于一项但未到 500,20ms 后数量仍不变且网络为零;gate 放开后才完成并重开核对全部来源 key。
|
||
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);}
|
||
}
|
||
|
||
// frame 端口引用子元素而子 canvas 又以 frame 为入口,形成必须一组提交的引用闭包;导入与深复制都应重映射,不连接原对象。
|
||
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;
|
||
// 协议替身按参与版本应用整个批次并回执移除 canvas,真实 IndexedDB 保存本地事务/历史。
|
||
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})};
|
||
// frame 的 ports.nodeId 指向来源 A,A.owner 指向来源 S,S.parent 又指向 F;来源顺序不能替代依赖规划。
|
||
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{
|
||
// 先导入 F/S/A 并核对端口指向新 A,再复制 F 的深闭包,要求 F′ 端口指向 A′。
|
||
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');
|
||
// 此 helper 核对提交后、undo/redo 和重开后的闭包,不订阅每个中间 Root;不能仅凭最终结果证明发布期间从无半组。
|
||
// 重开保持重映射;undo 不留下半组,redo 后子元素是第二代且端口仍连接同一新 ID。
|
||
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);}
|
||
}
|
||
|
||
// 深复制在准备和已加入前缀两个阶段都可暂停/重开;稳定映射、用户编辑、共享资源和上级 C→F′ 连线都必须保留。
|
||
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});
|
||
// 图片源带共享 assetId 和 runId;复制应复用资源但省略执行元数据,不生成新任务。
|
||
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}});
|
||
// 原 C→F 连线属于根 canvas;复制 F 与其子 A 不应删除原线,而应新增 C→F′。
|
||
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)]))});
|
||
// 协议替身按当前参与版本应用复制增量;每个 ACK 只返回参与 canvas 的完整成员。
|
||
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;
|
||
// 每次重开安装发布观察者,检查 queries.document/revision 与当次业务快照一致,避免复制暴露错位索引。
|
||
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}]);
|
||
// 准备来源仍为 duplicate-snapshot 时暂停,重开后选择集合与暂停状态应保留,再继续规划。
|
||
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);
|
||
// 等首个来源提交再暂停,这次保存的是执行前缀;随后用户修改复制出来的 frame 标题。
|
||
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();
|
||
// 重开为 duplicate-plan 且 stableIds 不变,继续时只补尾部,不能重新创建前缀覆盖用户编辑。
|
||
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');
|
||
// 终态作业被 undo 后重开不能自动复活被撤销对象,查询中的子 canvas 绑定也应移除。
|
||
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);}
|
||
}
|
||
|
||
// 500 个兄弟元素的 DFS 规划游标要真正持久化,而不是每次恢复重新从选择/成员扫描;准备阶段不应提前发布内容。
|
||
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}});
|
||
// frame 下 500 个成员,加上被复制 canvas,共应产生 502 个来源记录与稳定映射。
|
||
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;
|
||
// 内存服务核对参与版本,返回局部规范模型;大集合遍历及 checkpoint 使用真实引擎和存储。
|
||
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}]);
|
||
// 最多等 30 秒进入有 work 的 DFS 规划状态;命中后取消订阅和截止定时器。
|
||
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();}});});
|
||
// pause 后持久化准备 v4、DFS 栈、sourceToken/sha256,且 committedSourceKeys 仍为零。
|
||
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);
|
||
// 重开比较保存游标字节;恢复观察者要求 steps 不倒退,也不能重进 selection/canvases/members 等前期阶段。
|
||
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;}});
|
||
// 全部 502 项只分配一次,文档/画布计数和每个兄弟 owner 都核对,避免仅用 completed 状态宣称无重复。
|
||
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);}
|
||
}
|
||
|
||
// 原生 NDJSON 导入的 Blob、游标、稳定 ID 和失败报告跨重开保留;用户改过前缀后,继续导入与终态重开都不能覆盖或复活它。
|
||
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))}};};
|
||
// 来源含旧 incarnation=5、任务字段、提前出现的连线和晚到的 child;导入用新身份并清除执行元数据,非法尾行只记录失败。
|
||
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);
|
||
// 首项加入后暂停,记录其映射与源 fileId;用户在已加入前缀改标题再重开。
|
||
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);
|
||
// 恢复暂停后 resume,应得到四项已提交和一个失败,ID/用户标题不变,也不创建运行任务。
|
||
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');
|
||
// 关闭 engine 后独占读取持久源 Blob,必须精确等于原 NDJSON 文本,再重开验证历史。
|
||
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);
|
||
// undo 只移除后续导入批次,保留中断期间用户编辑过的第一项;终态作业重开后不能补回已撤销项。
|
||
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){
|
||
// 分别覆盖普通节点与 frame/canvas/member 整体生命期。
|
||
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})};
|
||
// 复用同一 IndexedDB 和服务状态重开,不复用旧 engine 的交互会话。
|
||
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();
|
||
// 创建和删除保留两个历史单元,但请求数为零;账本以 elided 结案而非 applied。
|
||
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');
|
||
// 重开保留本地证明和第一代墓碑,同时 ACK 基线没有该墓碑,不能混淆本地与服务高水位。
|
||
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');
|
||
// 未发送 undo/redo 仍可再次抵消,生成第二代本地墓碑但不发请求。
|
||
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');
|
||
// 无关 ACK 与完整规范刷新后,本地第二代高水位仍必须保留。
|
||
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');
|
||
// 先撤销无关编辑再撤销抵消删除,真正发送的新恢复为第三代;结构分支同时恢复成员及子 canvas。
|
||
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');
|
||
// redo 归档第三代,再重开核对墓碑;此前所有 elided 逻辑 ID 都不能混入后端请求。
|
||
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);}
|
||
}
|
||
}
|
||
|
||
// 一次有序请求可覆盖两个逻辑编辑并压缩字段 wire,但每个编辑仍独立进入 undo;丢 ACK 后恢复查询不能合并历史或更改冻结体。
|
||
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]);
|
||
// 长中间字符串使 wire 压缩效果可测,不能只看批次数便断言投影确实减少变化。
|
||
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;
|
||
// 按顺序应用操作,但一次批次只推进一次 canvas 版本;回执含全部逻辑 ID,首次响应故意丢失。
|
||
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);
|
||
// 两个命令同一窗口提交,第二个标题立即可见,不等 prepare 或网络批次。
|
||
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];
|
||
// 核对逻辑 ID 顺序、一次版本递增及实际 wire 长度;原始 before 和最终 after 必须保留。
|
||
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');
|
||
// 重开后只能查询原请求,body 和两个历史单元都保留,再分别核对两个 undo 的中间值。
|
||
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');
|
||
// redo 第一项只能回到 firstTitle,不能因传输合批跳过独立历史状态。
|
||
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);}
|
||
}
|
||
|
||
// 真实 Foundry v2 适配器连内存 transport,首次写入保存成功回执后返回超时;IndexedDB 恢复保留协议元数据、原体和原查询 key。
|
||
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;
|
||
// POST 使用增量 operations;GET 按 URL 的原 requestId 查询,服务成功模型版本为 2。
|
||
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();
|
||
// 冻结请求 protocolVersion 必须为 2,不得在序列化/恢复时默认为旧格式。
|
||
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();
|
||
// 前移时钟后仅查询一次;最终标题、单个 undo 单元和 applied 回执不因恢复重复出现。
|
||
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`)}
|
||
}
|
||
|
||
// 真实浏览器存储接最小 persistence 端口:detach 不终止任务,engine close/open 恢复同一未知请求、历史和操作事实,views 不持久化。
|
||
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 = [];
|
||
// 服务替身先执行 submit 却返回 unresolved,query 才确认成功,canonical 从已保存回执取规范基线。
|
||
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);
|
||
// 附加视图后编辑再 detach,后台仍应发送一次;视图不拥有引擎任务生命周期。
|
||
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();
|
||
// 前移恢复墙上时钟以触发重试查询,实际 timer 仍由浏览器提供。
|
||
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();
|
||
// 重开先 query 同一 ID/body,不再次 submit;pending 不得重放两遍,history/operations 都各一项。
|
||
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');
|
||
// 新引擎没有旧 transient view;回执协调完成后 Root 修订必须已越过本地持久化屏障。
|
||
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`); }
|
||
}
|
||
|
||
// 部分加载但子 canvas 已完整认证时,可在子作用域导入;源 Blob 与暂停状态重开可恢复,不必等无关根成员尾部。
|
||
async function loadingImportRecoveryCase(name,assert){
|
||
// loading v5 含来源树、owner manifest 和计数:根缺 A,子 C 已完整,原始完整模型保存在 fixture 服务。
|
||
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=[];
|
||
// 仅允许导入条件 POST,按参与 canvas 回执;不能为一次子导入强行读取/替换完整项目。
|
||
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};
|
||
// 根 canStart 拒绝、子允许;pause/reopen/resume 后导入完成但 loading 仍 v5,稳定 ID 再重开不变,undo 仅另发一次删除。
|
||
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);}
|
||
}
|
||
|
||
// loading v6 允许已认证子 canvas 暂存图片,上传与本地预览先行;丢 ACK 恢复查询后才放行文档,旧来源完成不能覆盖新生命期。
|
||
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=[];
|
||
// 文档服务只接参与 owner 的增量,父 canvas 仍未完成,不能让图片创建突破局部覆盖约束。
|
||
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:[]}}};}});
|
||
// 资产替身先保存元数据再抛丢 ACK;query 返回同一结果,假时钟只记录 now、不自动执行 timer。
|
||
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};
|
||
// 父禁入/子可入;图片立即本地可见但文档等待上传,重开后不重复上传,undo/redo 使用第二代,合并旧尾部仍保留资源与历史。
|
||
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);}
|
||
}
|
||
|
||
// 逐个覆盖 loading v1-v4 对应 recovery v7-v10;局部编辑/历史须保存,完成来源时强制完整 checkpoint,失败保持 partial 切点与发送屏障。
|
||
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";
|
||
// 源包含 A/B、已载入前缀只有 A;可选 count、作用域认证和 owner manifest 逐层增加能力。
|
||
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};
|
||
// 包装 claim 捕获实际 owner,仅供故障后直接检查上一个已保存 partial 镜像。
|
||
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:[]});
|
||
// 内存服务检查版本并回执;当前唯一 owner 尚不完整,因此编辑和历史遍历在来源完成前都不应冻结请求。
|
||
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()});
|
||
// 先改已加载 A,再重开验证对应 recovery 格式以及 undo/redo;加载 hold 期间请求数仍为零。
|
||
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');
|
||
// 保存已确认修订,包装原生 put,只在首次写入非 partial 格式 checkpoint 时排入写请求后抛错。
|
||
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;}
|
||
// 完整切点失败后,从 adapter 读到的仍是原 revision/partial 格式,网络不得放行。
|
||
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');
|
||
// 重试完整切点的保存,持久化屏障完成才放行发送;最终 loading 清空且旧尾部不能覆盖本地编辑。
|
||
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');
|
||
// 直接读取所有 checkpoint 与 journal,要求完成后不再残留 format>=7 的 partial 依赖,不能仅检查内存 loading 已清空。
|
||
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`);}
|
||
}
|
||
|
||
// 完整认证的子作用域可先写,未完整父作用域等待;丢 ACK 恢复后读来源版本与最新 ACK 版本必须独立保留,旧尾部再唤醒父写。
|
||
async function scopedAckRecoveryCase(name,assert,structural=false){
|
||
// 分别覆盖 loading v3 字段编辑和 v4 新元素创建,使用不同数据库。
|
||
name+=structural?"-structural":"-fields";
|
||
// prefix 已含子 C、但根缺 B 与跨层 E;完整计数/可选 manifest 证明哪个 owner 真正可写。
|
||
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;
|
||
// 局部 ACK 只返回当前参与 owner,不能用它覆盖整份旧来源或另一个尚未认证 owner。
|
||
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:[]});
|
||
// 首个子写实际成功后抛丢 ACK,GET 查询原回执;每个后续 POST 都校验最新 owner 版本。
|
||
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});
|
||
// 父 A 编辑等待,子修改或出生先发送;重开查询不增加 POST,child ACK 为 2 而 loadingRead 仍为捕获版本 1。
|
||
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');
|
||
// 接收原版本完整尾部只补缺失来源,唤醒父编辑;complete 后移除 loadingRead,但两个 owner 的新结果都保留。
|
||
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); }
|
||
}
|