101 lines
11 KiB
JavaScript
101 lines
11 KiB
JavaScript
'use strict';
|
|
const {vm,assert,functionSource}=require('./helpers/app-source');
|
|
const initSource=functionSource('init');
|
|
const initialSource=functionSource('readInitialDataAsync');
|
|
assert(initialSource.includes("Object.prototype.hasOwnProperty.call(bundle,'snapshot')?bundle.snapshot:bundle"),
|
|
'A fresh database bundle with snapshot:null can be mistaken for game data');
|
|
assert(!initSource.includes('fullResync')&&!initSource.includes("cloudOutboxDeleteKeys.add('full-resync')"),'Retired full-resync migration remains in startup');
|
|
assert(initSource.includes('recoveredDeletionTombstones')&&initSource.includes('deletedBoardRevisions.set(id,tombstone.rev')&&initSource.includes('deletedBoardAuthors.set(id,tombstone.revAuthor'),'Recovered deletion tombstones are not restored into the durable deletion queue');
|
|
async function runUpgradeCase(oldVersion){
|
|
const worldWrites=[],deletedStores=[];let openedVersion=0;
|
|
const genericStore=()=>({indexNames:{contains:()=>true},createIndex:()=>{},put:()=>{},clear:()=>{},get:()=>{const request={result:null};queueMicrotask(()=>request.onsuccess?.());return request}}),
|
|
worldsStore={...genericStore(),put:row=>worldWrites.push(row)},
|
|
stores=new Proxy({worlds:worldsStore},{get:(target,name)=>target[name]||(target[name]=genericStore())}),db={objectStoreNames:{contains:()=>true},deleteObjectStore:name=>deletedStores.push(name),close(){}},request={result:db,transaction:{objectStore:name=>stores[name]}};
|
|
const context={
|
|
Promise,console:{warn:()=>{}},idbAvailable:true,idbHealth:'unverified',worldDbAbandoned:false,worldDbPromise:null,worldDbName:'test-world',IDB_LAYOUT_VERSION:8,FIELD_STORAGE_FORMAT:2,SAVE_SCHEMA:31,WORLD_GENERATION:'current-only-test',IDB_STARTUP_TIMEOUT:8000,cloudApiEnabled:false,cloudOutboxReady:true,
|
|
indexedDB:{open(_name,version){openedVersion=version;queueMicrotask(()=>{request.onupgradeneeded?.({oldVersion});queueMicrotask(()=>request.onsuccess?.())});return request}},
|
|
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),createWorldEpoch:()=> 'world:upgrade-test',rememberWorldEpoch:()=>true,
|
|
defaultData:()=>({metas:{},states:{},worldEpoch:null}),globalForStorage:value=>({...value,metas:undefined,states:undefined}),
|
|
requestValue:request=>new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)}),
|
|
queueMicrotask,setTimeout,clearTimeout
|
|
};
|
|
vm.createContext(context);vm.runInContext(`${functionSource('openWorldDb')}\nthis.openWorldDb=openWorldDb;`,context);await context.openWorldDb();
|
|
return{openedVersion,worldWrites,deletedStores};
|
|
}
|
|
async function runCase({mirror,stored,loadError=null,journals=[],coverage=new Map()}){
|
|
const removedKeys=[];
|
|
const context={
|
|
console:{warn:()=>{}},idbAvailable:true,idbHealth:'ready',indexedDbExpectedAtStartup:true,worldDbAbandoned:false,worldDbPromise:null,cloudApiEnabled:false,cloudOutboxReady:true,cloudAvailable:false,activeStorageFormat:2,FIELD_STORAGE_FORMAT:2,IDB_STARTUP_TIMEOUT:8000,
|
|
storageKey:'save',loadNotices:[],readInitialData:()=>mirror,safeLocalGet:()=>mirror?'{mirror}':null,
|
|
readRecoveryJournals:()=>journals,safeLocalRemove:key=>{removedKeys.push(key);return true},
|
|
recoveryJournalsToCover:[],recoveredDeletionTombstones:new Map(),
|
|
startupRecoveredMetaIds:new Set(),startupRecoveredStateIds:new Set(),startupRecoveredDeletedIds:new Set(),
|
|
loadSnapshotFromDb:async()=>{if(loadError)throw loadError;return{snapshot:stored,coverage,databaseJournals:[],worldEpoch:'world:test-epoch'}},
|
|
withStartupTimeout:promise=>promise,
|
|
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),storedWorldEpoch:()=>null,createWorldEpoch:()=> 'world:test-epoch',rememberWorldEpoch:()=>true,
|
|
defaultData:()=>({metas:{},states:{}}),
|
|
attachWorldEpoch:(snapshot,epoch)=>Object.assign(snapshot||{metas:{},states:{}},{worldEpoch:epoch}),abandonWorldDatabase:()=>{context.worldDbAbandoned=true;context.idbAvailable=false;context.idbHealth='uncertain'},
|
|
isPlainObject:value=>!!value&&typeof value==='object'&&!Array.isArray(value),
|
|
deepClone:value=>JSON.parse(JSON.stringify(value)),
|
|
mergeBoardStates:(current,incoming)=>!current?JSON.parse(JSON.stringify(incoming)):!incoming?current:((incoming.rev||0)>(current.rev||0)?JSON.parse(JSON.stringify(incoming)):current),
|
|
normalizeCloudPending:value=>({metaIds:[...new Set(value?.metaIds||[])],stateIds:[...new Set(value?.stateIds||[])],deleted:[...new Set(value?.deleted||[])],globalChanged:value?.globalChanged===true}),
|
|
normalizeMeta:(_id,value)=>value,normalizeState:value=>value,
|
|
normalizeSnapshot:value=>value
|
|
};
|
|
vm.createContext(context);
|
|
vm.runInContext(`${functionSource('revisionVersion')}\n${functionSource('compareRevisionVersions')}\n${functionSource('setRecoveryJournalsToCover')}\n${functionSource('mergeRecoveryJournal')}\n${functionSource('mergeRecoveryJournals')}\n${functionSource('combineRecoveryJournals')}\n${functionSource('mergeV2RecoveryJournals')}\n${functionSource('readInitialDataAsync')}\nthis.logic={readInitialDataAsync,mergeRecoveryJournal};`,context);
|
|
return{result:await context.logic.readInitialDataAsync(),context,removedKeys};
|
|
}
|
|
(async()=>{
|
|
let test=await runCase({mirror:{metas:{},states:{}},stored:null});
|
|
assert(test.result.metas&&test.result.states,'A fresh database bundle did not produce a valid default snapshot');
|
|
const mirror={metas:{B0:{id:'B0'}},updatedAt:20},emptyDb={metas:{},updatedAt:30};
|
|
test=await runCase({mirror,stored:emptyDb});
|
|
assert(Object.keys(test.result.metas).length===0,'The retired compact mirror overrode the current database');
|
|
const db={metas:{B0:{id:'B0'},B1:{id:'B1'}},updatedAt:40};
|
|
test=await runCase({mirror,stored:db});
|
|
assert(Object.keys(test.result.metas).length===2&&test.result.metas.B1,'Populated database was not loaded');
|
|
const richerMirror={metas:{B0:{id:'B0'},B1:{id:'B1'},B2:{id:'B2'},B3:{id:'B3'}},updatedAt:35};
|
|
const staleDb={metas:{B0:{id:'B0'}},updatedAt:50};
|
|
test=await runCase({mirror:richerMirror,stored:staleDb});
|
|
assert(Object.keys(test.result.metas).length===1&&test.result.metas.B0&&!test.result.metas.B3,'A stale richer mirror resurrected a board deleted from the newer database');
|
|
const newerMirror={metas:{B0:{id:'B0'},B1:{id:'B1'}},updatedAt:60};
|
|
const olderSameSizeDb={metas:{B0:{id:'B0'},B1:{id:'B1'}},updatedAt:55};
|
|
test=await runCase({mirror:newerMirror,stored:olderSameSizeDb});
|
|
assert(Object.keys(test.result.metas).length===2&&test.result.updatedAt===55,'The current database was not authoritative over a retired mirror');
|
|
test=await runCase({mirror,stored:null,loadError:new Error('timeout')});
|
|
assert(Object.keys(test.result.metas).length===1&&test.result.metas.B0,'Database failure did not fall back to the compact mirror');
|
|
assert(test.context.idbAvailable===false&&test.context.idbHealth==='uncertain','Unverified database fallback remained writable');
|
|
const journalBase={metas:{B0:{id:'B0',rev:100}},states:{B0:{rev:100}},updatedAt:100,nextId:2};
|
|
const staleDelete={schema:30,worldGeneration:'test',updatedAt:101,sessionId:'delete-session',seq:1,deleted:[{id:'B0',rev:99}]};
|
|
const freshDelete={...staleDelete,seq:2,deleted:[{id:'B0',rev:101}]};
|
|
let merged=test.context.logic.mergeRecoveryJournal(journalBase,staleDelete);
|
|
assert(merged.metas.B0&&merged.states.B0,'Older deletion tombstone removed a newer board revision');
|
|
merged=test.context.logic.mergeRecoveryJournal(journalBase,freshDelete);
|
|
assert(!merged.metas.B0&&!merged.states.B0,'Newer deletion tombstone did not remove the covered board and state');
|
|
const currentDb={metas:{B0:{id:'B0',rev:10}},states:{B0:{rev:10}},updatedAt:100,nextId:2};
|
|
const coveredJournal={schema:30,worldGeneration:'test',updatedAt:105,sessionId:'covered',seq:3,_storageKey:'journal:covered',metas:[{id:'B2',rev:1}],states:[],deleted:[]};
|
|
const uncoveredJournal={schema:30,worldGeneration:'test',updatedAt:110,sessionId:'pending',seq:4,_storageKey:'journal:pending',metas:[{id:'B1',rev:11}],states:[],deleted:[]};
|
|
test=await runCase({mirror:currentDb,stored:currentDb,journals:[coveredJournal,uncoveredJournal],coverage:new Map([['covered',3],['pending',2]])});
|
|
assert(test.result.metas.B1&&!test.result.metas.B2,'Recovery coverage did not merge only the uncovered session journal');
|
|
assert(test.removedKeys.includes('journal:covered')&&!test.removedKeys.includes('journal:pending'),'Covered journal cleanup removed the wrong per-session journal');
|
|
assert(test.context.recoveryJournalsToCover.length===1&&test.context.recoveryJournalsToCover[0].sessionId==='pending','Uncovered journal was not retained for the next coverage commit');
|
|
let upgrade=await runUpgradeCase(2);assert(upgrade.openedVersion===8&&['metas','states','global','recovery','tombstones','outbox'].every(name=>upgrade.deletedStores.includes(name))&&upgrade.worldWrites.some(row=>row.status==='active'),'Existing database did not delete retired stores and initialize a current-only world');
|
|
upgrade=await runUpgradeCase(0);assert(upgrade.openedVersion===8&&upgrade.worldWrites.some(row=>row.status==='active'),'Fresh current-only world was not initialized');
|
|
const outboxContext={normalizeCloudPending:value=>({metaIds:[...new Set(value?.metaIds||[])],stateIds:[...new Set(value?.stateIds||[])],deleted:[...new Set(value?.deleted||[])],globalChanged:value?.globalChanged===true})};
|
|
vm.createContext(outboxContext);vm.runInContext(`${functionSource('cloudPendingFromOutboxRows')}\nthis.readOutbox=cloudPendingFromOutboxRows;`,outboxContext);
|
|
const pending=outboxContext.readOutbox([{type:'meta',id:'B1'},{type:'deleted',id:'B2'},{type:'global'}]);
|
|
assert(pending.metaIds[0]==='B1'&&pending.deleted[0]==='B2'&&pending.globalChanged,'Startup outbox rows were not integrated into pending cloud work');
|
|
const journalPrefix='journal:',journalValues=new Map([
|
|
['journal',JSON.stringify({schema:30,worldGeneration:'world',updatedAt:3,sessionId:'retired-single-key',seq:1})],
|
|
[`${journalPrefix}one`,JSON.stringify({schema:30,worldGeneration:'world',updatedAt:1,sessionId:'one',seq:2})],
|
|
[`${journalPrefix}two`,JSON.stringify({schema:30,worldGeneration:'world',updatedAt:2,sessionId:'two',seq:3})]
|
|
]),journalContext={
|
|
recoveryJournalKey:'journal',recoveryJournalPrefix:journalPrefix,LOCAL_MIRROR_MAX_BYTES:10000,SAVE_SCHEMA:30,WORLD_GENERATION:'world',
|
|
safeLocalKeys:prefix=>[...journalValues.keys()].filter(key=>key.startsWith(prefix)),safeLocalGet:key=>journalValues.get(key)||null
|
|
};
|
|
vm.createContext(journalContext);vm.runInContext(`${functionSource('readRecoveryJournals')}\nthis.readRecoveryJournals=readRecoveryJournals;`,journalContext);
|
|
const discovered=journalContext.readRecoveryJournals();
|
|
assert(discovered.map(journal=>journal.sessionId).join(',')==='one,two'&&discovered.every(journal=>journal._storageKey),'Current per-session recovery journals were not discovered and ordered');
|
|
console.log('Storage recovery integration passed');
|
|
})().catch(error=>{console.error(error);process.exitCode=1});
|