262 lines
28 KiB
JavaScript
262 lines
28 KiB
JavaScript
import { createBrowserStorage } from '../../src/lib/canvas-runtime/storage.ts';
|
|
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
|
import { createDocument, createRegistry, builtinComponents, applyChanges } 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';
|
|
|
|
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}});
|
|
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();
|
|
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();
|
|
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;
|
|
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();
|
|
const restored=engine.getSnapshot();assert(restored.runtime.recoveries.has(plan.id),'recovery audit disappeared');assert(restored.runtime.ledger.requests.get(old.id).body===old.body&&restored.runtime.ledger.requests.get(old.id).outcome==='not-applied','old rejected request was rewritten');
|
|
assert(restored.runtime.ledger.requests.get(replacement.id).body===replacement.body,'replacement identity changed after reopening');assert(calls.filter(call=>call.method==='POST').length===2,'recovery repeated a server write');
|
|
assert(engine.editor.history.undo().status==='applied','replanned history did not restore');await engine.settle();assert(engine.getSnapshot().document.elements.get('a').data.title==='remote','undo did not restore the reviewed server value');
|
|
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-conflict-replan`);}
|
|
}
|
|
|
|
async function canonicalRefreshCase(name,assert){
|
|
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
|
|
const target={id:'a',incarnation:1},element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}};
|
|
const original=createDocument(root,[{id:root,parent:null}],[element]),storage=createBrowserStorage({name:`${name}-canonical-refresh`}),calls=[];let server=original,version=1,engine;
|
|
const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:version}});
|
|
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);for(const operation of body.operations)server=applyChanges(server,operation.changes);version++;return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)}};};
|
|
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
|
|
try{
|
|
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();
|
|
const historyId=engine.getSnapshot().history.undo[0],request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];
|
|
server=applyChanges(server,[{kind:'field',target,field:'geometry',before:element.geometry,after:{...element.geometry,x:400}}]);version++;
|
|
const session=await engine.prepareRecovery();assert(session.conflict===undefined,'clean canonical refresh reported a conflict');
|
|
assert(engine.getSnapshot().document.elements.get('a').geometry.x===0,'preview mutated the working document');
|
|
assert(engine.editor.history.undo().code==='recovery_in_progress','history bypassed the recovery fence');
|
|
assert((await session.apply()).status==='applied','fresh canonical state did not publish');await engine.close();
|
|
engine=await openProjectEngine(options);const restored=engine.getSnapshot();
|
|
assert(restored.document.elements.get('a').geometry.x===400&&restored.document.elements.get('a').data.title==='local','canonical refresh did not survive IndexedDB recovery');
|
|
assert(restored.runtime.versions.get(root)===3&&restored.history.undo[0]===historyId,'refresh lost its version/history cut');
|
|
assert(restored.runtime.ledger.requests.size===1&&restored.runtime.ledger.requests.get(request.id).body===request.body,'refresh changed an original request');
|
|
assert(calls.filter(call=>call.method==='POST').length===1,'refresh or reopen resent an applied write');
|
|
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-canonical-refresh`);}
|
|
}
|
|
|
|
async function runRecoveryCase(name,assert){
|
|
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),runId=crypto.randomUUID();
|
|
const registry=createRegistry(builtinComponents()),element={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}},original=createDocument(root,[{id:root,parent:null}],[element]);
|
|
const storage=createBrowserStorage({name:`${name}-runs`}),calls=[];let accepted,after,plan,engine;
|
|
const chunk=(document,version)=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:{},versions:{[root]:version}});
|
|
const transport=async request=>{
|
|
calls.push(request);
|
|
if(request.method==='POST'){
|
|
const input=JSON.parse(request.body),configured=applyChanges(original,[{kind:'field',target:{id:'a',incarnation:1},field:'data.ai',before:null,beforePresent:false,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:{},propagate:false},afterPresent:true}]);
|
|
plan={steps:[{step_id:'step-a',board_id:root,node_id:'a',target_incarnation:1,canonical_root:root}],edges:[],frozen_versions:{[root]:2}};
|
|
accepted={protocol_version:2,request_id:request.requestId,operation_id:input.operation_id,admission:'accepted',status:'queued',run_id:runId,plan,canvas_version:2,document_format_version:1,target_incarnation:1,model:chunk(configured,2)};
|
|
after=applyChanges(configured,[{kind:'field',target:{id:'a',incarnation:1},field:'data.text',before:'original',after:'durable generated',beforePresent:true,afterPresent:true}]);
|
|
return{status:502,body:{error:'response_lost'}};
|
|
}
|
|
if(request.path.includes('/ai/operations/'))return{status:200,body:accepted};
|
|
return{status:200,body:{run_id:runId,status:'succeeded',plan,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'},{seq:2,run_id:runId,type:'step.applied',step_id:'step-a',board_id:root,node_id:'a',target_incarnation:1,document_format_version:1,canvas_version:3,model:chunk(after,3)}],next_seq:2,has_more:false,outputs:[],steps:[]}};
|
|
};
|
|
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport}),runs:createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport})};
|
|
try{
|
|
engine=await openProjectEngine(options);const detach=engine.sessions.attach('view');engine.startRun({target:{id:'a',incarnation:1},boardId:root,modelId:'text',prompt:'Generate',parameters:{},propagate:false});detach();await engine.settle();
|
|
const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(engine.getSnapshot().runtime.runs.size===0,'lost admission was guessed as resolved');await engine.close();
|
|
engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}});
|
|
for(let i=0;i<100&&engine.getSnapshot().runtime.runs.get(runId)?.cursor!==2;i++)await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
|
|
assert(calls.filter(call=>call.method==='POST').length===1,'recovery invoked a second generation');assert(calls.some(call=>call.path.endsWith('/ai/operations/'+request.id)),'recovery lost the original admission identity');
|
|
assert(engine.getSnapshot().runtime.runs.get(runId)?.cursor===2&&engine.getSnapshot().document.elements.get('a').data.text==='durable generated','run and canonical result did not publish together');assert(engine.getSnapshot().history.undo.length===0,'ACK replay created user history');await engine.close();
|
|
const leader=await storage.claim(options.scope);try{const image=await storage.recover(leader);assert(image.payload.runtime.runs[0][1].cursor===2,'run cursor was not durable');assert(image.payload.document.elements[0].data.text==='durable generated','cursor advanced without durable document');}finally{await leader.release();}
|
|
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-runs`);}
|
|
}
|
|
|
|
async function resourceRecoveryCase(name, assert) {
|
|
const scope = { accountId: crypto.randomUUID(), workspaceId: crypto.randomUUID(), projectId: crypto.randomUUID() };
|
|
const registry = createRegistry(builtinComponents()), document = createDocument(scope.projectId, [{ id: scope.projectId, parent: null }], []);
|
|
const storage = createBrowserStorage({ name: `${name}-resources` }), receipts = new Map(), uploads = [], queries = [], writes = [];
|
|
const assets = createFoundryAssets({ workspaceId: scope.workspaceId, transport: async input => {
|
|
if (input.method === 'POST') {
|
|
uploads.push(input); const file = input.body.get('image');
|
|
assert(await file.text() === 'durable-image-bytes', 'upload reconstructed different file bytes');
|
|
receipts.set(input.requestId, { protocol_version: 2, request_id: input.requestId, status: 'applied', asset: { id: input.resourceId, bytes: file.size, mime_type: 'image/png', width: 2, height: 1, url: assets.sourceURL(input.resourceId) } });
|
|
return { status: 502, body: { error: 'lost_upload_ack' } };
|
|
}
|
|
queries.push(input); return { status: 200, body: receipts.get(input.path.split('/').at(-1)) };
|
|
} });
|
|
let server = document;
|
|
const persistence = {
|
|
compile: (intent, id, snapshot) => ({ payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: { [scope.projectId]: snapshot.runtime.versions.get(scope.projectId) } }),
|
|
async submit(request) { writes.push(request); server = applyChanges(server, JSON.parse(request.body).intent.changes); return { outcome: 'applied' }; },
|
|
async query() { return { outcome: 'unresolved' }; },
|
|
canonical: () => ({ document: server, versions: new Map([[scope.projectId, writes.length + 1]]) }),
|
|
};
|
|
const options = { scope, registry, document, versions: new Map([[scope.projectId, 1]]), storage, persistence, assets };
|
|
let engine, resource;
|
|
try {
|
|
engine = await openProjectEngine(options);
|
|
const result = engine.stageFiles([{ name: 'picture.png', bytes: new Blob(['durable-image-bytes'], { type: 'image/png' }) }], ([ref], tx) => {
|
|
resource = ref;
|
|
tx.dispatch({ type: 'element.create', element: { id: 'picture', incarnation: 1, componentId: 'image', dataVersion: 1, structure: 'node', ownerCanvasId: scope.projectId, data: { title: 'Picture', text: '', src: ref.src, assetId: ref.id }, geometry: { x: 10, y: 20, width: 100, height: 80, rotation: 0 } } });
|
|
});
|
|
assert(result.status === 'applied' && engine.getSnapshot().document.elements.has('picture'), 'pending image was hidden until prepare/upload');
|
|
const handle = await engine.displayResource(resource.id); assert(handle.url.startsWith('blob:'), 'pending image did not expose a local preview'); handle.release();
|
|
await engine.settle(); assert(uploads.length === 1 && writes.length === 0, 'document was sent before its resource was confirmed');
|
|
await engine.close();
|
|
const leader = await storage.claim(scope), image = await storage.recover(leader);
|
|
assert(await (await storage.readFile(leader, resource.id)).text() === 'durable-image-bytes', 'file bytes did not survive actual IndexedDB recovery');
|
|
assert(image.payload.runtime.resources[0][1].id === resource.id && image.payload.document.elements[0].data.assetId === resource.id, 'resource metadata and element belong to different durable cuts');
|
|
await leader.release();
|
|
engine = await openProjectEngine({ ...options, clock: { now: () => Date.now() + 100000, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: id => clearTimeout(id) } });
|
|
await new Promise(resolve => setTimeout(resolve, 10)); await engine.settle();
|
|
assert(uploads.length === 1 && queries.length === 1 && writes.length === 1, 'reopen uploaded again or lost the pending element operation');
|
|
assert(queries[0].path.endsWith(uploads[0].requestId), 'recovery queried a replacement request identity');
|
|
assert(engine.getSnapshot().runtime.resources.get(resource.id).stored.id === resource.id, 'confirmed asset changed resource identity');
|
|
assert(engine.getSnapshot().history.undo.length === 1 && engine.getSnapshot().document.elements.size === 1, 'resource ACK recreated the node/history');
|
|
} finally { await engine?.close(); storage.close(); indexedDB.deleteDatabase(`${name}-resources`); }
|
|
}
|
|
|
|
async function protocolRecoveryCase(name,assert){
|
|
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000003',account='10000000-0000-4000-8000-000000000004';
|
|
const registry=createRegistry(builtinComponents()),document=createDocument(root,[{id:root,parent:null}],[{id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:260,height:200,rotation:0},data:{title:'a',text:''}}]);
|
|
const calls=[],receipts=new Map();let server=document;
|
|
const transport=async input=>{
|
|
calls.push(input);
|
|
if(input.method==='POST'){
|
|
const batch=JSON.parse(input.body);server=applyChanges(server,batch.operations[0].changes);
|
|
receipts.set(batch.request_id,{protocol_version:2,request_id:batch.request_id,status:'applied',operation_ids:batch.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:2},complete:false,removedCanvasIds:[]}});
|
|
return{status:502,body:{error:'upstream_timeout'}};
|
|
}
|
|
return{status:200,body:receipts.get(input.path.split('/').at(-1))};
|
|
};
|
|
const storage=createBrowserStorage({name:`${name}-protocol`}),persistence=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport});
|
|
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document,versions:new Map([[root,1]]),storage,persistence};let engine;
|
|
try{
|
|
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'durable-v2'}});await engine.settle();
|
|
const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(request.protocolVersion===2,'new protocol metadata was replaced by a legacy default');await engine.close();
|
|
engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(callback,delay)=>setTimeout(callback,delay),clearTimer:handle=>clearTimeout(handle)}});
|
|
await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
|
|
assert(calls.length===2&&calls[1].method==='GET','protocol 2 recovery must query the original request');
|
|
assert(engine.getSnapshot().document.elements.get('a').data.title==='durable-v2'&&engine.getSnapshot().history.undo.length===1,'protocol recovery changed working content or duplicated history');
|
|
const recovered=engine.getSnapshot().runtime.ledger.requests.get(request.id);assert(recovered.body===request.body&&recovered.outcome==='applied','protocol recovery rewrote request bytes or lost the receipt');
|
|
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-protocol`)}
|
|
}
|
|
|
|
async function engineRecoveryCase(name, assert) {
|
|
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' }, registry = createRegistry(builtinComponents());
|
|
const document = createDocument('project', [{ id: 'canvas', parent: null }], [{ id: 'element', incarnation: 1, ownerCanvasId: 'canvas', componentId: 'text', dataVersion: 1, structure: 'node', geometry: { x: 0, y: 0, width: 100, height: 100, rotation: 0 }, data: { title: 'initial', text: '' } }]);
|
|
let server = document; const receipts = new Map(), calls = [];
|
|
const port = {
|
|
compile(intent, requestId, snapshot) { return { payload: { intent, requestId }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(snapshot.runtime.versions) }; },
|
|
async submit(request) { calls.push(['submit', request]); const intent = JSON.parse(request.body).intent; server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['canvas', 2]]) }); return { outcome: 'unresolved' }; },
|
|
async query(request) { calls.push(['query', request]); return { outcome: 'applied', canonical: { receiptId: request.id } }; },
|
|
canonical(request) { return receipts.get(request.id); },
|
|
};
|
|
const storage = createBrowserStorage({ name: `${name}-engine`, checkpointEvery: 4 }), options = { scope, registry, document, versions: new Map([['canvas', 1]]), storage, persistence: port };
|
|
let engine;
|
|
try {
|
|
engine = await openProjectEngine(options);
|
|
const detach = engine.sessions.attach('view');
|
|
engine.editor.dispatch({ type: 'element.data', target: { id: 'element', incarnation: 1 }, patch: { title: 'durable edit' } });
|
|
detach(); await engine.settle();
|
|
assert(calls.length === 1, 'detaching last view must not abort persistent work');
|
|
const request = calls[0][1];
|
|
await engine.close();
|
|
const futureClock = { now: () => Date.now() + 100_000, setTimer: (callback, delay) => setTimeout(callback, delay), clearTimer: handle => clearTimeout(handle) };
|
|
engine = await openProjectEngine({ ...options, clock: futureClock });
|
|
await new Promise(resolve => setTimeout(resolve, 10)); await engine.settle();
|
|
assert(calls.length === 2 && calls[1][0] === 'query', 'recovered dispatched request must query before a new submit');
|
|
assert(calls[1][1].id === request.id && calls[1][1].body === request.body, 'request ID/body must survive real IndexedDB recovery exactly');
|
|
assert(engine.getSnapshot().document.elements.get('element').data.title === 'durable edit', 'recovered pending changes must not replay twice');
|
|
assert(engine.getSnapshot().history.undo.length === 1 && engine.getSnapshot().operations.size === 1, 'history and ledger must recover the same state cut');
|
|
assert(engine.sessions.getSnapshot().views.size === 0, 'transient views must not be recovered as document data');
|
|
assert(engine.getDurability().rootRevision === engine.getDurability().durableRootRevision, 'final receipt and replay must cross the durable barrier');
|
|
} finally { await engine?.close(); storage.close(); indexedDB.deleteDatabase(`${name}-engine`); }
|
|
}
|
|
|
|
export async function run() {
|
|
const results = [], name = `headless-test-${crypto.randomUUID()}`;
|
|
const scope = { accountId: 'test-account', workspaceId: 'test-workspace', projectId: 'test-project' };
|
|
const storage = createBrowserStorage({ name, checkpointEvery: 3 });
|
|
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); };
|
|
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 {
|
|
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: 2 } }), 'unsupported_recovery_version');
|
|
results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject');
|
|
|
|
// The first put succeeds before a later duplicate resource aborts the whole transaction.
|
|
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) });
|
|
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');
|
|
|
|
// Deliberately remove a journal item to exercise the corruption boundary, not an empty fallback.
|
|
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];
|
|
const tamper = database.transaction('journal', 'readwrite'), read = tamper.objectStore('journal').get(storageKey);
|
|
let intact;
|
|
read.onsuccess = () => { intact = read.result; const corrupted = structuredClone(intact); corrupted.image.payload.document.title = 'altered'; 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');
|
|
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');
|
|
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 resourceRecoveryCase(name, assert);
|
|
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
|
|
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');
|
|
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');
|
|
return results;
|
|
} finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); }
|
|
}
|