import { captureException, captureBreadcrumb } from '../platform/exceptions'; import { sequential } from '../shared/async'; let runningMethods = new Set(); let currentContext = null; let mutatingMethods = new WeakMap(); let globalMutationsEnabled = false; let _latestHandlerNames = []; export function mutator(handler) { mutatingMethods.set(handler, true); return handler; } export function isMutating(handler) { return mutatingMethods.has(handler); } async function flushRunningMethods() { // Give the client some time to invoke new requests await wait(200); while (runningMethods.size > 0) { // Wait for all of them await Promise.all([...runningMethods.values()]); // We give clients more time to make other requests. This lets them continue // to do an async workflow await wait(100); } } function wait(time) { return new Promise(resolve => setTimeout(resolve, time)); } export async function runHandler( handler, args?, { undoTag, name }: { undoTag?; name? } = {}, ) { // For debug reasons, track the latest handlers that have been // called _latestHandlerNames.push(name); if (_latestHandlerNames.length > 5) { _latestHandlerNames = _latestHandlerNames.slice(-5); } if (mutatingMethods.has(handler)) { return runMutator(() => handler(args), { undoTag }); } // When closing a file, it clears out all global state for the file. That // means any async workflows currently executed would be cut off. We handle // this by letting all async workflows finish executing before closing the // file if (name === 'close-budget') { await flushRunningMethods(); } let promise = handler(args); runningMethods.add(promise); promise.then(() => { runningMethods.delete(promise); }); return promise; } // These are useful for tests. Only use them in tests. export function enableGlobalMutations() { if (process.env.NODE_ENV === 'test') { globalMutationsEnabled = true; } } export function disableGlobalMutations() { if (process.env.NODE_ENV === 'test') { globalMutationsEnabled = false; } } function _runMutator<T extends () => Promise<unknown>>( func: T, initialContext = {}, ) { currentContext = initialContext; return func().finally(() => { currentContext = null; }) as ReturnType<T>; } // Type cast needed as TS looses types over nested generic returns export const runMutator = sequential(_runMutator) as typeof _runMutator; export function withMutatorContext(context, func) { if (currentContext == null && !globalMutationsEnabled) { captureBreadcrumb('Recent methods: ' + _latestHandlerNames.join(', ')); captureException(new Error('withMutatorContext: mutator not running')); // See comment below. This is not an error right now, but it will // be in the future. return func(); } let prevContext = currentContext; currentContext = { ...currentContext, ...context }; return func().finally(() => { currentContext = prevContext; }); } export function getMutatorContext() { if (currentContext == null) { captureBreadcrumb({ category: 'server', message: 'Recent methods: ' + _latestHandlerNames.join(', '), }); // captureException(new Error('getMutatorContext: mutator not running')); // For now, this is a non-fatal error. It will be in the future, // but this is relatively non-critical (undo just won't work) so // return an empty context. When we have more confidence that // everything is running inside a mutator, throw an error. return {}; } if (currentContext == null && globalMutationsEnabled) { return {}; } return currentContext; }