Skip to content

Add Promise-based versions of methods in public ShareDB API #523

@ericyhwang

Description

@ericyhwang

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.
  • 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 use Doc#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 or await 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:

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions