131 lines
12 KiB
JavaScript
131 lines
12 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 { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
|
|
|
|
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 protocolRecoveryCase(name,assert);
|
|
results.push('protocol 2 service adapter recovers exact frozen bytes and reconciles a lost ACK through real IndexedDB');
|
|
return results;
|
|
} finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); }
|
|
}
|