2026-07-30 15:13:53 +09:00
|
|
|
'use strict';
|
|
|
|
|
const {vm,app,assert,functionSource}=require('./helpers/app-source');
|
|
|
|
|
|
|
|
|
|
const persistSource=functionSource('persistDirtyToDb');
|
|
|
|
|
const startupSource=functionSource('loadSnapshotFromDb');
|
|
|
|
|
const startupV2Source=functionSource('loadV2SnapshotFromDb');
|
|
|
|
|
const initialSource=functionSource('readInitialDataAsync');
|
|
|
|
|
const replaceSource=functionSource('beginWorldReplacement');
|
|
|
|
|
const clearSource=functionSource('clearDatabaseWorld');
|
|
|
|
|
|
|
|
|
|
assert(startupSource.includes('loadV2SnapshotFromDb')&&startupV2Source.includes("db.transaction(['worlds','boardIndex','outboxV2','recoveryV2','tombstonesV2'],'readonly')"),
|
|
|
|
|
'Startup does not read the snapshot, recovery coverage, and tombstones atomically');
|
|
|
|
|
assert(startupV2Source.includes("objectStore('recoveryV2').getAll")&&startupV2Source.includes("objectStore('tombstonesV2').getAll"),
|
|
|
|
|
'Startup omits database recovery journals or tombstones');
|
|
|
|
|
assert(!initialSource.includes('loadRecoveryCoverage('),
|
|
|
|
|
'Startup still performs the recovery coverage read in a second transaction');
|
|
|
|
|
assert(replaceSource.includes('await preserveRecoveryDurably('),
|
|
|
|
|
'World replacement does not await a verified recovery backup');
|
2026-08-01 16:06:14 +09:00
|
|
|
assert(functionSource('runStatusRetry').includes('statusRetryAction')&&!app.includes('復元用バックアップがありません。'),
|
|
|
|
|
'The shared-server retry button still attempts obsolete local-backup recovery');
|
2026-07-30 15:13:53 +09:00
|
|
|
assert(clearSource.includes("activateReadyWorldV2({epoch:newEpoch,expected,world},{kind:'reset'})"),
|
|
|
|
|
'Fresh-world reset does not use atomic epoch activation');
|
|
|
|
|
assert(!app.includes('worldMutationLockDepth')&&functionSource('withWorldMutationLock').includes("mode:'exclusive'"),
|
|
|
|
|
'World mutation locking still bypasses unrelated asynchronous callers');
|
|
|
|
|
assert(functionSource('persistNow').includes('if(options.lockHeld===true)return run()')&&functionSource('expandMetaNow').includes('lockHeld:true'),
|
|
|
|
|
'Nested expansion persistence can deadlock behind a queued lock waiter');
|
2026-08-01 16:06:14 +09:00
|
|
|
assert(!app.includes('worldInitReady')&&!app.includes('deferredWorldSignals')&&!app.includes('BroadcastChannel'),
|
|
|
|
|
'Retired cross-tab board synchronization remains');
|
|
|
|
|
assert(functionSource('fetchCurrentSharedWorldStatus').includes("status.singleWorld===true")&&functionSource('initCloudSync').includes('resetClientToSingleSharedWorld()'),
|
|
|
|
|
'Startup does not require and adopt the single server-authoritative world');
|
2026-07-30 15:13:53 +09:00
|
|
|
|
|
|
|
|
const request=result=>({result});
|
|
|
|
|
function memoryStore(initial=[],keyOf=row=>row.id){
|
|
|
|
|
const normalizeKey=key=>Array.isArray(key)?key.join(':'):key,rows=new Map(initial.map(row=>[normalizeKey(keyOf(row)),structuredClone(row)]));
|
|
|
|
|
return{
|
|
|
|
|
rows,
|
|
|
|
|
get:key=>request(rows.has(normalizeKey(key))?structuredClone(rows.get(normalizeKey(key))):null),
|
|
|
|
|
put:row=>{rows.set(normalizeKey(keyOf(row)),structuredClone(row));return request(keyOf(row))},
|
|
|
|
|
delete:key=>{rows.delete(normalizeKey(key));return request(undefined)},
|
|
|
|
|
clear:()=>{rows.clear();return request(undefined)}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
function persistenceContext({localMeta=null,localState=null,existingMeta=null,existingState=null,deletion=null,identityEpoch='world:test'}={}){
|
|
|
|
|
const existingId=existingMeta?.id||'B0',existingIndex=existingMeta?{epoch:'world:test',id:existingId,metaRev:existingMeta.rev||0,stateRev:existingState?.rev||0}:null;
|
|
|
|
|
const remoteGlobal={worldEpoch:'world:test',globalRev:20,globalRevAuthor:'remote',nextId:9,cursorStyle:'remote'};
|
|
|
|
|
const stores={
|
|
|
|
|
control:memoryStore([{key:'active',activeFormat:2,activeEpoch:identityEpoch}],row=>row.key),
|
|
|
|
|
worlds:memoryStore([{epoch:'world:test',status:'active',createdAt:1,source:{kind:'fresh'},global:remoteGlobal}],row=>row.epoch),
|
|
|
|
|
boardIndex:memoryStore(existingIndex?[existingIndex]:[],row=>`${row.epoch}:${row.id}`),
|
|
|
|
|
boardPuzzles:memoryStore(existingMeta?[{epoch:'world:test',id:existingId,metaRev:existingMeta.rev||0,meta:existingMeta}]:[],row=>`${row.epoch}:${row.id}`),
|
|
|
|
|
boardStates:memoryStore(existingState?[{epoch:'world:test',id:existingId,stateRev:existingState.rev||0,value:existingState}]:[],row=>`${row.epoch}:${row.id}`),
|
|
|
|
|
outboxV2:memoryStore([],row=>`${row.epoch}:${row.key}`),recoveryV2:memoryStore([],row=>`${row.epoch}:${row.key}`),tombstonesV2:memoryStore([],row=>`${row.epoch}:${row.id}`)
|
|
|
|
|
};
|
|
|
|
|
const id=localMeta?.id||existingMeta?.id||'B0';
|
|
|
|
|
const data={
|
|
|
|
|
worldEpoch:'world:test',globalRev:10,globalRevAuthor:'local',nextId:2,cursorStyle:'local',
|
|
|
|
|
metas:localMeta?{[id]:structuredClone(localMeta)}:{},
|
|
|
|
|
states:localState?{[id]:structuredClone(localState)}:{},
|
|
|
|
|
updatedAt:0,clockFloor:0
|
|
|
|
|
};
|
|
|
|
|
const context={
|
|
|
|
|
console,structuredClone,sessionId:'local',lastRevision:100,data,
|
|
|
|
|
dirtyMetaIds:new Set(localMeta?[id]:[]),dirtyStateIds:new Set(localState?[id]:[]),
|
|
|
|
|
deletedBoardIds:new Set(deletion?[id]:[]),
|
|
|
|
|
deletedBoardRevisions:new Map(deletion?[[id,deletion.rev]]:[]),
|
|
|
|
|
deletedBoardAuthors:new Map(deletion?[[id,deletion.revAuthor]]:[]),
|
|
|
|
|
recoveryJournalsToCover:[],recoveryJournalSeq:-1,recoveryWalPromise:Promise.resolve(),
|
|
|
|
|
cloudOutboxDeleteKeys:new Set(),cloudJournalMetaIds:new Set(),cloudJournalStateIds:new Set(),
|
|
|
|
|
cloudJournalDeletedIds:new Set(deletion?[id]:[]),cloudApiEnabled:false,
|
|
|
|
|
globalDirty:true,globalChangeSeq:1,cloudJournalGlobalChanged:true,worldSignalSeq:0,
|
|
|
|
|
idbAvailable:true,activeStorageFormat:2,FIELD_STORAGE_FORMAT:2,storageAccessError:null,normalizedStateObjects:new WeakSet(),
|
|
|
|
|
boardIndexSummaries:new Map(),
|
|
|
|
|
statsDirty:false,resetHistory:[],
|
|
|
|
|
pruneAndCount:()=>{},
|
|
|
|
|
hasPendingPersistence:()=>context.globalDirty||context.dirtyMetaIds.size>0||context.dirtyStateIds.size>0||context.deletedBoardIds.size>0,
|
|
|
|
|
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),
|
|
|
|
|
storedWorldEpoch:()=>null,createWorldEpoch:()=> 'world:test',rememberWorldEpoch:()=>true,
|
|
|
|
|
revisionVersion:value=>({rev:value?.rev||0,revAuthor:value?.revAuthor||value?.author||''}),
|
|
|
|
|
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||a?.author||'').localeCompare(String(b?.revAuthor||b?.author||'')),
|
|
|
|
|
newerRevisionValue:(a,b)=>context.compareRevisionVersions(a,b)>=0?a:b,
|
|
|
|
|
trustedNow:()=>1234,
|
|
|
|
|
globalForStorage:(source,updatedAt)=>({...source,metas:undefined,states:undefined,updatedAt,clockFloor:updatedAt}),
|
|
|
|
|
mergeGlobalRecords:(stored,incoming)=>context.compareRevisionVersions(
|
|
|
|
|
{rev:incoming.globalRev,revAuthor:incoming.globalRevAuthor},
|
|
|
|
|
{rev:stored?.globalRev,revAuthor:stored?.globalRevAuthor}
|
|
|
|
|
)>=0?incoming:stored,
|
|
|
|
|
applyGlobalRecordToData:record=>Object.assign(data,structuredClone(record)),
|
|
|
|
|
staleWorldEpochError:()=>Object.assign(new Error('stale world'),{code:'STALE_WORLD_EPOCH'}),
|
|
|
|
|
metaRowsForStorage:ids=>ids.map(rowId=>data.metas[rowId]&&structuredClone(data.metas[rowId])).filter(Boolean),
|
|
|
|
|
stateRowsForStorage:ids=>ids.map(rowId=>data.states[rowId]&&({id:rowId,value:structuredClone(data.states[rowId])})).filter(Boolean),
|
|
|
|
|
snapshotForStorage:()=>({}),writeCompactMirror:()=>true,
|
|
|
|
|
openWorldDb:async()=>({transaction:()=>({objectStore:name=>stores[name],abort(){this.aborted=true}})}),
|
|
|
|
|
transactionDone:async()=>{},requestValue:async req=>req.result,
|
|
|
|
|
updateStorageRevision:()=>{},scheduleMirrorCheckpoint:()=>{},
|
|
|
|
|
normalizeMeta:(_id,value)=>structuredClone(value),normalizeState:value=>structuredClone(value||{paths:[],rev:0,revAuthor:''}),
|
|
|
|
|
metaFromV2Records:(_index,puzzle)=>puzzle?.meta?structuredClone(puzzle.meta):null,
|
|
|
|
|
stateFromV2Record:(_index,state)=>state?.value?structuredClone(state.value):null,
|
|
|
|
|
puzzleRecordV2:(meta,epoch)=>({epoch,id:meta.id,metaRev:meta.rev||0,meta:structuredClone(meta)}),
|
|
|
|
|
stateRecordV2:(rowId,state,epoch)=>({epoch,id:rowId,stateRev:state?.rev||0,value:structuredClone(state)}),
|
|
|
|
|
summarizeBoardV2:(meta,state,epoch)=>({epoch,id:meta.id,metaRev:meta.rev||0,stateRev:state?.rev||0}),
|
|
|
|
|
fieldBoundsFromMetas:()=>({minX:0,minY:0,maxX:1,maxY:1}),SAVE_SCHEMA:31,WORLD_GENERATION:'current-only-test',
|
|
|
|
|
clearRecoveryJournalIfCovered:()=>{},broadcastWorldSignal:()=>{},scheduleCloudPush:()=>{},
|
|
|
|
|
refreshWorldView:()=>{context.refreshes++},refreshes:0,
|
2026-07-31 12:54:46 +09:00
|
|
|
interactionActive:()=>false,waitForInteractionSettle:async()=>{},
|
2026-07-30 15:13:53 +09:00
|
|
|
perfStart:()=>0,perfEnd:()=>{},perfGauge:()=>{}
|
|
|
|
|
};
|
|
|
|
|
vm.createContext(context);
|
|
|
|
|
vm.runInContext(`${persistSource}\nthis.persistDirtyToDb=persistDirtyToDb;`,context);
|
|
|
|
|
return{context,stores,id};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function verifyPersistenceConflicts(){
|
|
|
|
|
{
|
|
|
|
|
const localMeta={id:'B0',x:1,rev:5,revAuthor:'local'},localState={paths:[],rev:5,revAuthor:'local'};
|
|
|
|
|
const existingMeta={id:'B0',x:7,rev:10,revAuthor:'remote'},existingState={paths:[[1]],rev:11,revAuthor:'remote'};
|
|
|
|
|
const {context,stores}=persistenceContext({localMeta,localState,existingMeta,existingState});
|
|
|
|
|
await context.persistDirtyToDb();
|
|
|
|
|
assert(stores.boardPuzzles.rows.get('world:test:B0').meta.x===7&&stores.boardStates.rows.get('world:test:B0').value.rev===11,
|
|
|
|
|
'A stale tab overwrote a newer database row');
|
|
|
|
|
assert(context.data.metas.B0.x===7&&context.data.states.B0.rev===11&&context.refreshes===1,
|
|
|
|
|
'A stale in-memory board was not reconciled to the committed winner');
|
|
|
|
|
assert(context.data.cursorStyle==='remote'&&context.data.nextId===9,
|
|
|
|
|
'A stale global record overwrote the newer global winner');
|
|
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
const existingMeta={id:'B0',x:4,rev:20,revAuthor:'remote'},existingState={paths:[],rev:21,revAuthor:'remote'};
|
|
|
|
|
const {context,stores}=persistenceContext({
|
|
|
|
|
existingMeta,existingState,deletion:{rev:15,revAuthor:'local'}
|
|
|
|
|
});
|
|
|
|
|
await context.persistDirtyToDb();
|
|
|
|
|
assert(stores.boardPuzzles.rows.has('world:test:B0')&&stores.boardStates.rows.has('world:test:B0')&&!stores.tombstonesV2.rows.has('world:test:B0'),
|
|
|
|
|
'A stale deletion removed a newer database board');
|
|
|
|
|
assert(context.data.metas.B0?.rev===20&&context.data.states.B0?.rev===21&&context.refreshes===1,
|
|
|
|
|
'A rejected stale deletion was not restored in the losing tab');
|
|
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
const existingMeta={id:'B0',rev:20,revAuthor:'remote'},existingState={paths:[],rev:20,revAuthor:'remote'};
|
|
|
|
|
const {context,stores}=persistenceContext({
|
|
|
|
|
existingMeta,existingState,deletion:{rev:25,revAuthor:'local'}
|
|
|
|
|
});
|
|
|
|
|
await context.persistDirtyToDb();
|
|
|
|
|
assert(!stores.boardPuzzles.rows.has('world:test:B0')&&!stores.boardStates.rows.has('world:test:B0'),
|
|
|
|
|
'A newer deletion did not remove the older board rows');
|
|
|
|
|
assert(stores.tombstonesV2.rows.get('world:test:B0')?.rev===25,
|
|
|
|
|
'A committed deletion did not leave a durable tombstone');
|
|
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
const localMeta={id:'B0',rev:5,revAuthor:'local'};
|
|
|
|
|
const {context}=persistenceContext({localMeta,identityEpoch:'world:replacement'});
|
|
|
|
|
let error=null;try{await context.persistDirtyToDb()}catch(caught){error=caught}
|
|
|
|
|
assert(error?.code==='STALE_WORLD_EPOCH'&&context.dirtyMetaIds.has('B0'),
|
|
|
|
|
'A stale tab was allowed to write into a replaced world epoch');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function verifyGlobalMerge(){
|
2026-07-31 12:54:46 +09:00
|
|
|
const {normalizeSpecialMechanics}=require('../shared-contracts');
|
2026-07-30 15:13:53 +09:00
|
|
|
const context={
|
|
|
|
|
deepClone:value=>structuredClone(value),structuredClone,
|
2026-07-31 12:54:46 +09:00
|
|
|
normalizeSpecialMechanics,
|
2026-07-30 15:13:53 +09:00
|
|
|
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),
|
|
|
|
|
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||'').localeCompare(String(b?.revAuthor||'')),
|
|
|
|
|
bonusEventTotal:events=>Object.values(events||{}).reduce((sum,value)=>sum+(value||0),0)
|
|
|
|
|
};
|
|
|
|
|
vm.createContext(context);
|
|
|
|
|
vm.runInContext(`${functionSource('mergeGlobalRecords')}\nthis.mergeGlobalRecords=mergeGlobalRecords;`,context);
|
|
|
|
|
const merged=context.mergeGlobalRecords(
|
|
|
|
|
{worldEpoch:'world:test',globalRev:30,globalRevAuthor:'remote',cursorStyle:'remote',nextId:8,clockFloor:11,cloudRevision:3,bonusEvents:{A:4},quarantine:{B:{failedAt:5}},lastSolveAt:10,specialMechanicsSeen:['warp'],combo:2},
|
|
|
|
|
{worldEpoch:'world:test',globalRev:20,globalRevAuthor:'local',cursorStyle:'local',nextId:12,clockFloor:15,cloudRevision:9,bonusEvents:{A:2,C:7},quarantine:{B:{failedAt:8}},lastSolveAt:14,specialMechanicsSeen:['lock'],combo:4}
|
|
|
|
|
);
|
|
|
|
|
assert(merged.cursorStyle==='remote'&&merged.nextId===12&&merged.clockFloor===15&&merged.cloudRevision===9,
|
|
|
|
|
'Global LWW and monotonic fields were not merged independently');
|
|
|
|
|
assert(merged.bonusEvents.A===4&&merged.bonusEvents.C===7&&merged.bonusScore===11,
|
|
|
|
|
'Global additive bonus records were lost during a concurrent merge');
|
|
|
|
|
assert(merged.quarantine.B.failedAt===8&&merged.lastSolveAt===14&&!Object.prototype.hasOwnProperty.call(merged,'combo')&&merged.specialMechanicsSeen.join(',')==='lock,warp',
|
|
|
|
|
'Global timestamped records, encounter memory, or retired combo data were merged incorrectly');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function verifyDurableBackup(){
|
|
|
|
|
const source=functionSource('preserveRecoveryDurably');
|
|
|
|
|
const failing={
|
|
|
|
|
Date,safeLocalSet:()=>false,sessionStorage:{setItem(){throw new Error('blocked')}},
|
|
|
|
|
persistRecoveryEnvelope:async()=>false,volatileRecovery:null,recoveryStorageKey:'recovery'
|
|
|
|
|
};
|
|
|
|
|
vm.createContext(failing);vm.runInContext(`${source}\nthis.preserveRecoveryDurably=preserveRecoveryDurably;`,failing);
|
|
|
|
|
let rejected=false;try{await failing.preserveRecoveryDurably('save','reset')}catch(_){rejected=true}
|
|
|
|
|
assert(rejected,'Destructive replacement proceeded without any durable backup');
|
|
|
|
|
const database={
|
|
|
|
|
Date,safeLocalSet:()=>false,sessionStorage:{setItem(){throw new Error('blocked')}},
|
|
|
|
|
persistRecoveryEnvelope:async()=>true,volatileRecovery:null,recoveryStorageKey:'recovery'
|
|
|
|
|
};
|
|
|
|
|
vm.createContext(database);vm.runInContext(`${source}\nthis.preserveRecoveryDurably=preserveRecoveryDurably;`,database);
|
|
|
|
|
const result=await database.preserveRecoveryDurably('save','reset');
|
|
|
|
|
assert(!result.browserCopy&&result.databaseCopy,'A verified database-only backup was not accepted');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function verifyRevisionSeed(){
|
|
|
|
|
const context={
|
|
|
|
|
lastRevision:3,data:{},recoveredDeletionTombstones:new Map([['B9',{rev:81}]])
|
|
|
|
|
};
|
|
|
|
|
vm.createContext(context);
|
|
|
|
|
vm.runInContext(`${functionSource('seedRevisionClock')}\nthis.seedRevisionClock=seedRevisionClock;`,context);
|
|
|
|
|
const maximum=context.seedRevisionClock({
|
|
|
|
|
globalRev:50,clockFloor:0,metas:{B0:{rev:60}},states:{B0:{rev:70}}
|
|
|
|
|
});
|
|
|
|
|
assert(maximum===81&&context.lastRevision===81,'The revision clock was not seeded from all persisted row types');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(async()=>{
|
|
|
|
|
await verifyPersistenceConflicts();
|
|
|
|
|
verifyGlobalMerge();
|
|
|
|
|
await verifyDurableBackup();
|
|
|
|
|
verifyRevisionSeed();
|
|
|
|
|
console.log('Concurrency and replacement safety test passed');
|
|
|
|
|
})().catch(error=>{console.error(error);process.exitCode=1});
|