-
Notifications
You must be signed in to change notification settings - Fork 456
Description
The last time this was brought up was back in 2018, in #202, @nateps expressed said he wasn't really a fan on promises in ShareDB. However, I can't find his reasoning written down anywhere.
Nate's not really an active maintainer anymore ever since handing ShareDB off to @alecgibson and me a while back, so the two of us can decide to add promises if we want :)
I know Alec's a fan of promises in general, so let's try and get it done!
After thinking through it and doing some testing, I think we should:
- Add new promise versions of the async methods, instead of turning the existing ones into hybrid callback-or-promise.
- The hybrid approach interacts poorly with the Connection and Doc
.on('error', listener)
"default error handler" listeners - See below for details. I suspect this issue with the hybrid approach was partly what Nate had concerns about.
- The hybrid approach interacts poorly with the Connection and Doc
- Have the new methods immediately throw if a Promise implementation isn't globally available.
- No shimming of Promise inside ShareDB. If a consumer wants to use promises and still needs to support IE 11, they can install a global Promise polyfill at the root of their app, if they don't already have one.
Thoughts, questions, comments, concerns? Anything I missed in my analysis below?
Adding new promisified versions of the methods
Example, using TypeScript syntax:
class Doc {
// Existing callback-based method remains unchanged
submitOp(op: Op, options: Options, callback?: Callback): void;
submitOp(op: Op, callback?: Callback): void;
// New Promise-based method
submitOpPromised(op: Op, options?: Options): Promise<void>;
}
Advantages:
- This is entirely backwards-compatible. If a consumer still wants to call
submitOp()
a bunch of times synchronously and then useDoc#on('error', listener)
to handle errors, they can still do so. - If a consumer calls the new
submitOpPromised
, that's an explicit signal that they intend to use the returned promise, including correctly handling errors, so we don't have to worry about emitting it to the Doc error listener. - Simpler method signatures
Downside, we have double the number of such methods, and consumers have to change to calling the new method. However, even with the next auto-promise version, consumers would still have to add then
/catch
or await
to their call sites to actually take advantage of the promises, so I feel like it's not a big deal to have them call a separate method.
Automatically returning Promise if no callback is given
Advantages:
- Many hybrid callback/promise APIs take this approach, and [WIP] Promisify client-facing methods #225 is an example of how this might work in ShareDB.
- No need to call a new method, though consumers would still need to add either
then
/catch
orawait
at the call sites to actually use promises, so they'd be modifying those lines in their code anyways. - Fewer methods in the ShareDB API, though each method has a more complicated call signature.
Problem 1
As that PR description alludes to, this would be a breaking change for on('error', listener)
, which acts as a default "unhandled error" handler at the Connection/Doc level when an explicit callback isn't provided to a method that accepts one. We could make this non-breaking with some work, see further down.
There are tests exercising that the error event is emitted when no callback is given, such as this one:
Lines 875 to 883 in 083f8ac
doc.create({age: 3}); | |
expect(doc.version).equal(null); | |
expect(doc.data).eql({age: 3}); | |
doc.on('error', function(err) { | |
expect(err.message).equal('Custom error'); | |
expect(doc.version).equal(0); | |
expect(doc.data).equal(undefined); | |
done(); | |
}); |
If we start auto-returning a Promise:
- Say that
doc.create({age: 3});
starts returning a Promise since no callback was given. - The promise gets rejected, and because there's no handling of the rejection, the global unhandled promise rejection handling gets triggered.
- In Node 15+, that defaults to terminating the Node process. That's despite the presence of the
doc.on('error', ...)
handler.
There is a way to make this backwards-compatible with more work. In the callback-to-promise shim code, when calling reject(errorFromCallback)
, also check if connectionOrDoc.listenerCount('error') > 0
. If so, then add a no-op catch to the original promise to not trigger the global unhandled exception handler, and emit the error on the Connection/Doc.
Problem 2
With the backwards-compatible workaround, the Connection/Doc error listener will fire even for errors that get handled further on in a promise chain or higher up in the stack. It no longer solely acts as a Connection/Doc-level handler for otherwise unhandled errors, since it receives errors that did get handled.
Couple examples, where the error handler receives an error that gets handled:
doc.on('error', console.log);
function() {
doc.create({age: 3})
.then(processResult)
.catch(handleError);
}
async () => {
try {
return doc.create({age: 3});
} catch (error) {
handleError(error);
}
}
There's not really a clean way around this, since the handling of the error isn't directly on the original promise. A process/window level unhandled rejection listener that forwards back to the Connection/Doc could mostly work. One issue, the listener for sharedb wouldn't be able to prevent the event from continuing on to other unhandled rejection listeners.